From db9a52096c0666b06e4733e3918829e6ebebde85 Mon Sep 17 00:00:00 2001 From: Gerald Date: Thu, 12 Dec 2024 14:54:41 +0100 Subject: [PATCH] Release @argent-x/extension@6.20.5 --- .eslintignore | 3 +- .github/workflows/chromatic.yml | 2 - .github/workflows/pull-request.yml | 523 +- .github/workflows/release.yml | 95 +- .github/workflows/upgrade-tests.yml | 228 + .nvmrc | 2 +- package.json | 31 +- packages/e2e/.eslintrc.js | 45 - packages/e2e/.gitignore | 3 +- packages/e2e/Dockerfile | 6 - .../e2e/extension/src/languages/ILanguage.ts | 149 - .../e2e/extension/src/specs/tokens.spec.ts | 33 - packages/e2e/extension/src/test.ts | 149 - packages/e2e/package.json | 17 +- .../e2e/{extension => }/playwright.config.ts | 23 +- packages/e2e/shared/cfg/global.teardown.ts | 16 - packages/e2e/shared/cfg/test.ts | 75 - packages/e2e/shared/config.ts | 35 - packages/e2e/shared/src/Utils.ts | 21 - packages/e2e/shared/src/slack.spec.ts | 11 - packages/e2e/shared/src/slack.ts | 16 - packages/e2e/{extension => }/src/config.ts | 37 +- packages/e2e/{extension => }/src/fixtures.ts | 1 + packages/e2e/src/languages/ILanguage.ts | 3 + .../{extension => }/src/languages/en/index.ts | 28 +- .../{extension => }/src/languages/index.ts | 7 +- .../src/page-objects/Account.ts | 192 +- .../src/page-objects/Activity.ts | 0 .../src/page-objects/AddressBook.ts | 9 + .../{extension => }/src/page-objects/Dapps.ts | 10 +- .../src/page-objects/DeveloperSettings.ts | 10 +- .../src/page-objects/ExtensionPage.ts | 134 +- .../src/page-objects/Messages.ts | 0 .../src/page-objects/Navigation.ts | 4 +- .../src/page-objects/Network.ts | 26 +- .../{extension => }/src/page-objects/Nfts.ts | 2 +- .../src/page-objects/Preferences.ts | 2 +- .../src/page-objects/Settings.ts | 45 +- packages/e2e/src/page-objects/Swap.ts | 95 + packages/e2e/src/page-objects/TokenDetails.ts | 94 + .../src/page-objects/Wallet.ts | 65 +- .../src/specs/accountSettings.spec.ts | 24 +- .../src/specs/addressBook.spec.ts | 48 +- .../{extension => }/src/specs/dapps.spec.ts | 21 +- .../specs/defaultAccount2smartAccount.spec.ts | 46 +- packages/e2e/src/specs/importAccounts.spec.ts | 87 + .../src/specs/invalidAddress.spec.ts | 9 +- .../{extension => }/src/specs/links.spec.ts | 0 .../src/specs/multisig.spec.ts | 62 +- .../{extension => }/src/specs/network.spec.ts | 22 +- .../{extension => }/src/specs/nfts.spec.ts | 8 +- .../src/specs/recovery.spec.ts | 28 +- .../src/specs/sendMaxFunds.spec.ts | 11 +- .../src/specs/sendPartialFunds.spec.ts | 11 +- .../src/specs/smartAccount.spec.ts | 80 +- packages/e2e/src/specs/swap.spec.ts | 118 + packages/e2e/src/specs/tokenDetails.spec.ts | 185 + packages/e2e/src/specs/tokens.spec.ts | 35 + packages/e2e/src/specs/upgrade.spec.ts | 406 + .../{extension => }/src/specs/welcome.spec.ts | 2 +- packages/e2e/src/test.ts | 183 + packages/e2e/src/utils/Clipboard.ts | 46 + .../e2e/{shared/src => src/utils}/assets.ts | 55 +- .../e2e/{shared/src => src/utils}/common.ts | 3 + packages/e2e/src/utils/download.ts | 52 + packages/e2e/src/utils/getBranchVersion.sh | 24 + packages/e2e/src/utils/getBranchVersion.ts | 22 + packages/e2e/src/utils/global.teardown.ts | 16 + packages/e2e/src/utils/index.ts | 20 + packages/e2e/src/utils/qaUtils.ts | 44 + packages/e2e/src/utils/slackNotif.js | 38 + packages/e2e/src/utils/unzip.sh | 58 + packages/e2e/src/utils/unzip.ts | 36 + packages/e2e/until-failure | 9 + packages/eslint-plugin-local/package.json | 2 +- packages/extension/.env.example | 12 +- packages/extension/.eslintrc.base.js | 2 + packages/extension/.eslintrc.js | 12 + packages/extension/CHANGELOG.md | 66 + packages/extension/ampli.json | 4 +- .../extension/build/getSafeGetCommitHash.ts | 2 +- .../build/htmlWebpackInlineStylePlugin.ts | 52 + packages/extension/build/reactDevTools.ts | 2 +- .../extension/build/transformManifestJson.ts | 2 +- packages/extension/manifest/v2.json | 2 +- packages/extension/manifest/v3.json | 2 +- packages/extension/package.json | 79 +- .../extension/scripts/check-bundle-size.ts | 64 + packages/extension/scripts/export.ts | 2 +- packages/extension/src/ampli/index.ts | 395 +- packages/extension/src/assets/barlow/LICENSE | 93 + .../barlow/barlow-latin-300-normal.woff | Bin 0 -> 18420 bytes .../barlow/barlow-latin-300-normal.woff2 | Bin 0 -> 20992 bytes .../barlow/barlow-latin-400-normal.woff | Bin 0 -> 18464 bytes .../barlow/barlow-latin-400-normal.woff2 | Bin 0 -> 21144 bytes .../barlow/barlow-latin-500-normal.woff | Bin 0 -> 18416 bytes .../barlow/barlow-latin-500-normal.woff2 | Bin 0 -> 20960 bytes .../barlow/barlow-latin-600-normal.woff | Bin 0 -> 18748 bytes .../barlow/barlow-latin-600-normal.woff2 | Bin 0 -> 21796 bytes .../barlow/barlow-latin-700-normal.woff | Bin 0 -> 18644 bytes .../barlow/barlow-latin-700-normal.woff2 | Bin 0 -> 21724 bytes .../extension/src/assets/default-tokens.json | 20 +- .../extension/src/assets/known-dapps.json | 148 - .../extension/src/assets/onboarding/2fa.svg | 29 + .../src/assets/onboarding/account-smart.svg | 36 + .../assets/onboarding/account-standard.svg | 26 + .../src/assets/onboarding/default.svg | 84 + .../src/assets/onboarding/email-wrong.svg | 24 + .../extension/src/assets/onboarding/email.svg | 26 + .../onboarding/finish/2fa-protection.svg | 16 + .../onboarding/finish/download-mobile.svg | 22 + .../onboarding/finish/explore-dapps.svg | 10 + .../onboarding/finish/follow-us-on-x.svg | 4 + .../onboarding/finish/on-chain-recovery.svg | 42 + .../assets/onboarding/finish/session-keys.svg | 16 + .../src/assets/onboarding/improve.svg | 27 + .../assets/onboarding/password-created.svg | 19 + .../src/assets/onboarding/password.svg | 20 + .../src/assets/staking/liquid-staking.png | Bin 0 -> 168556 bytes .../src/assets/staking/native-staking.png | Bin 0 -> 182418 bytes .../extension/src/background/accountDeploy.ts | 7 +- .../src/background/accountDeployAction.ts | 38 +- .../src/background/accountMessaging.ts | 5 +- .../src/background/accountUpgrade.ts | 15 +- .../src/background/actionHandlers.ts | 51 +- .../src/background/actionMessaging.ts | 4 +- .../extension/src/background/activeTabs.ts | 3 +- .../extension/src/background/background.ts | 10 +- packages/extension/src/background/crypto.ts | 3 +- .../src/background/devnet/declareAccounts.ts | 20 +- packages/extension/src/background/index.ts | 1 + packages/extension/src/background/init.ts | 1 + .../src/background/keys/messagingKeys.ts | 5 +- .../messageHandling/addMessageListeners.ts | 64 +- .../src/background/messageHandling/handle.ts | 11 +- .../background/messageHandling/messages.ts | 2 +- .../src/background/migrations/index.ts | 28 +- .../migrations/preAuthorizations/old.ts | 2 +- .../src/background/migrations/token/v5.9.ts | 2 +- .../migrations/wallet/testnet2Accounts.ts | 1 + .../migrations/wallet/v5.8.1.test.ts | 6 +- .../background/migrations/wallet/v5.8.1.ts | 13 +- .../src/background/miscellaneousMessaging.ts | 4 +- .../multisig/multisigDeployAction.ts | 41 +- .../multisig/worker/MultisigWorker.test.ts | 330 + .../multisig/worker/MultisigWorker.ts | 426 +- .../src/background/multisig/worker/index.ts | 20 +- .../src/background/networkMessaging.ts | 4 +- packages/extension/src/background/nonce.ts | 81 - .../INonceManagementService.ts | 8 + .../NonceManagementService.test.ts | 147 + .../nonceManagement/NonceManagementService.ts | 107 + .../src/background/nonceManagement/index.ts | 12 + .../src/background/nonceManagement/store.ts | 31 + .../worker/INonceManagementWorker.ts | 5 + .../worker/NonceManagementWorker.test.ts | 138 + .../worker/NonceManagementWorker.ts | 45 + .../nonceManagement/worker/index.ts | 8 + .../background/preAuthorizationMessaging.ts | 4 +- packages/extension/src/background/respond.ts | 2 +- .../account/worker/AccountWorker.test.ts | 236 +- .../services/account/worker/AccountWorker.ts | 199 +- .../services/account/worker/index.ts | 4 + .../action/BackgroundActionService.ts | 8 +- .../action/IBackgroundActionService.ts | 2 +- .../services/activity/ActivityService.test.ts | 31 +- .../services/activity/ActivityService.ts | 52 +- .../services/activity/IActivityService.ts | 2 +- .../cache/ActivityCacheService.test.ts | 25 +- .../activity/cache/ActivityCacheService.ts | 12 +- .../activity/cache/mergeAndSortActivities.ts | 7 +- .../cache/worker/ActivityCacheWorker.test.ts | 48 +- .../cache/worker/ActivityCacheWorker.ts | 38 +- .../services/activity/cache/worker/index.ts | 25 +- .../src/background/services/activity/index.ts | 1 + .../background/services/activity/schema.ts | 14 + .../activity/worker/ActivityWorker.test.ts | 114 +- .../activity/worker/ActivityWorker.ts | 97 +- .../services/analytics/AnalyticsWoker.ts | 4 +- .../BackgroundArgentAccountService.ts | 14 +- .../src/background/services/dev/DevWorker.ts | 40 + .../src/background/services/dev/index.ts | 8 + .../IBackgroundInvestmentService.ts | 14 + .../investments/InvestmentService.test.ts | 128 + .../services/investments/InvestmentService.ts | 114 + .../background/services/investments/index.ts | 12 + .../investments/worker/InvestmentWorker.ts | 186 + .../services/investments/worker/index.ts | 23 + .../knownDapps/worker/KnownDappsWorker.ts | 9 +- .../multisig/BackgroundMultisigService.ts | 58 +- .../multisig/IBackgroundMultisigService.ts | 4 +- .../network/BackgroundNetworkService.test.ts | 9 +- .../network/BackgroundNetworkService.ts | 13 +- .../network/IBackgroundNetworkService.ts | 2 +- .../src/background/services/network/index.ts | 4 + .../services/network/worker/NetworkWorker.ts | 51 +- .../services/nft/worker/NftsWorker.ts | 58 +- .../background/services/nft/worker/index.ts | 4 +- .../notifications/NotificationService.ts | 12 +- .../services/onRamp/OnRampService.ts | 7 +- .../worker/OnboardingWorker.test.ts | 7 +- .../onboarding/worker/OnboardingWorker.ts | 6 +- .../BackgroundRecoveryService.test.ts | 4 +- .../recovery/BackgroundRecoveryService.ts | 2 +- .../BackgroundRiskAssessmentService.ts | 8 +- .../schedule/worker/ScheduleWorker.ts | 4 +- ...BackgroundOutsideSignatureReviewService.ts | 23 +- .../services/staking/StakingService.ts | 252 + .../src/background/services/staking/index.ts | 10 + .../services/token/worker/TokenWorker.test.ts | 61 +- .../services/token/worker/TokenWorker.ts | 69 +- .../BackgroundTokenDetailsService.ts | 49 + .../background/services/tokenDetails/index.ts | 12 + .../tokenDetails/tokenDetailsError.ts | 17 + ...BackgroundTransactionReviewService.test.ts | 102 +- .../BackgroundTransactionReviewService.ts | 357 +- .../services/transactionReview/index.ts | 2 + .../services/transactionReview/types.ts | 31 + .../worker/TransactionReviewWorker.test.ts | 4 +- .../BaseTransactionTrackingService.test.ts | 2 +- .../BaseTransactionTrackingService.ts | 2 +- .../worker/TransactionTrackerWorker.test.ts | 17 +- .../worker/TransactionTrackerWorker.ts | 32 +- .../transactionTracker/worker/index.ts | 3 +- .../transactions/worker/TransactionsWorker.ts | 2 +- .../services/ui/BackgroundUIService.ts | 21 +- .../services/ui/IBackgroundUIService.ts | 7 +- .../services/worker/schedule/decorators.ts | 17 +- .../schedule/mockBackgroundUIService.ts | 5 +- .../worker/schedule/mockSessionService.ts | 5 +- .../test/__fixtures__/activities.ts | 266 + .../src/background/tokenMessaging.ts | 5 +- .../src/background/transactions/badgeText.ts | 23 +- .../transactions/determineUpdates.ts | 3 +- .../transactions/onupdate/changeGuardian.ts | 2 +- .../transactions/onupdate/declareContract.ts | 2 +- .../transactions/onupdate/deployAccount.ts | 2 +- .../background/transactions/onupdate/index.ts | 4 +- .../transactions/onupdate/multisigUpdates.ts | 5 +- .../background/transactions/onupdate/nonce.ts | 15 - .../transactions/onupdate/upgrade.ts | 6 +- .../transactions/sources/onchain.spec.ts | 6 +- .../transactions/sources/onchain.ts | 2 +- .../transactions/sources/voyager.model.ts | 5 +- .../transactions/sources/voyager.ts | 9 +- .../transactions/transactionAdapter.ts | 5 +- .../transactions/transactionExecution.ts | 79 +- .../transactions/transactionMessaging.ts | 524 +- .../background/transactions/transformers.ts | 6 +- .../trpc/procedures/account/select.ts | 4 +- .../trpc/procedures/account/upgrade.ts | 40 +- .../accountMessaging/cancelEscape.ts | 2 +- .../accountMessaging/changeGuardian.ts | 4 +- .../escapeAndChangeGuardian.ts | 8 +- .../getAccountDeploymentPayload.ts | 7 +- .../getEncryptedPrivateKey.ts | 7 +- .../accountMessaging/getNextPublicKey.ts | 14 +- .../accountMessaging/getPublicKey.ts | 7 +- .../getPublicKeysBufferForMultisig.ts | 2 +- .../procedures/dappMessaging/connectDapp.ts | 2 +- .../trpc/procedures/importAccount/import.ts | 17 + .../trpc/procedures/importAccount/index.ts | 8 + .../trpc/procedures/importAccount/validate.ts | 24 + .../investments/getAllInvestments.ts | 11 + .../getStrkDelegatedStakingInvestments.ts | 12 + .../trpc/procedures/investments/index.ts | 9 + .../trpc/procedures/staking/claim.ts | 15 + .../trpc/procedures/staking/index.ts | 14 + .../procedures/staking/initiateUnstake.ts | 15 + .../trpc/procedures/staking/stake.ts | 15 + .../trpc/procedures/staking/stakeCalldata.ts | 15 + .../trpc/procedures/staking/unstake.ts | 15 + .../procedures/swap/getSwapQuoteForPay.ts | 9 +- .../trpc/procedures/swap/makeSwap.ts | 2 +- .../procedures/tokens/fetchAccountBalance.ts | 9 +- .../trpc/procedures/tokens/fetchDetails.ts | 2 +- .../procedures/tokens/fetchTokenActivities.ts | 22 + .../trpc/procedures/tokens/fetchTokenGraph.ts | 19 + .../procedures/tokens/getAccountBalance.ts | 2 +- .../procedures/tokens/getAllTokenBalances.ts | 2 +- .../trpc/procedures/tokens/getTokenBalance.ts | 22 + .../procedures/tokens/hideTokenProcedure.ts | 15 + .../trpc/procedures/tokens/index.ts | 10 + .../trpc/procedures/tokens/reportSpamToken.ts | 16 + .../transactionEstimate/accountDeploy.ts | 8 +- .../transactionEstimate/estimate.ts | 4 +- .../procedures/transactionEstimate/helpers.ts | 9 +- .../transactionReview/getTransactionHash.ts | 34 - .../procedures/transactionReview/index.ts | 2 - .../transactionReview/simulateAndReview.ts | 9 +- .../trpc/procedures/transfer/send.ts | 2 +- .../udc/declareContractProcedure.ts | 11 +- .../procedures/udc/deployContractProcedure.ts | 13 +- .../procedures/udc/getConstructorParams.ts | 2 +- .../background/trpc/procedures/ui/index.ts | 6 + .../background/trpc/procedures/ui/openUI.ts | 19 + .../extension/src/background/trpc/router.ts | 14 + .../extension/src/background/trpc/trpc.ts | 10 +- .../extension/src/background/udcAction.ts | 84 +- .../extension/src/background/udcMessaging.ts | 10 +- .../WalletAccountStarknetService.test.ts | 32 +- .../account/WalletAccountStarknetService.ts | 84 +- .../wallet/backup/WalletBackupService.ts | 6 +- .../crypto/WalletCryptoSharedService.ts | 10 +- .../crypto/WalletCryptoStarknetService.ts | 186 +- .../deployment/IWalletDeploymentService.ts | 15 +- .../WalletDeploymentStarknetService.test.ts | 56 +- .../WalletDeploymentStarknetService.ts | 325 +- .../extension/src/background/wallet/index.ts | 102 +- .../wallet/recovery/IWalletRecoveryService.ts | 4 +- .../WalletRecoverySharedService.test.ts | 18 +- .../recovery/WalletRecoverySharedService.ts | 17 +- .../recovery/WalletRecoveryStarknetService.ts | 112 +- .../wallet/session/WalletSessionService.ts | 19 +- .../src/background/wallet/test.utils.ts | 194 +- .../extension/src/background/wallet/utils.ts | 2 +- .../src/background/walletSingleton.ts | 11 +- packages/extension/src/background/workers.ts | 6 + .../extension/src/inpage/ArgentXAccount.ts | 12 +- .../extension/src/inpage/ArgentXAccount4.ts | 12 +- .../extension/src/inpage/ArgentXAccount5.ts | 12 +- .../extension/src/inpage/ArgentXProvider.ts | 6 +- .../extension/src/inpage/ArgentXProvider4.ts | 7 +- .../extension/src/inpage/ArgentXProvider5.ts | 6 +- packages/extension/src/inpage/index.ts | 6 +- packages/extension/src/inpage/messaging.ts | 2 +- packages/extension/src/inpage/provider.ts | 42 + .../addDeclareTransaction.ts | 2 +- .../addStarknetChain.ts | 6 +- .../requestMessageHandlers/deploymentData.ts | 2 +- .../inpage/requestMessageHandlers/errors.ts | 3 +- .../invokeTransaction.ts | 4 +- .../requestMessageHandlers/requestAccounts.ts | 2 +- .../requestMessageHandlers/signTypedData.ts | 2 +- .../switchStarknetChain.ts | 2 +- .../requestMessageHandlers/watchAsset.ts | 2 +- .../src/inpage/starknetWindowObject.ts | 2 +- packages/extension/src/inpage/trpcClient.ts | 2 +- .../src/messages/__tests__/relayer.test.ts | 2 +- .../src/messages/__tests__/windowMock.mock.ts | 4 +- .../src/messages/exchange/bidirectional.ts | 2 +- packages/extension/src/messages/index.ts | 2 +- .../src/messages/messenger/window.ts | 2 +- packages/extension/src/navigator.d.ts | 4 + .../getAccountCairoVersionFromChain.ts | 9 +- .../getAccountClassHashFromChain.test.ts | 18 +- .../details/getAccountClassHashFromChain.ts | 27 +- .../getAccountDeployStatusFromChain.ts | 11 +- .../details/getAccountEscapeFromChain.ts | 11 +- .../details/getAccountGuardiansFromChain.ts | 11 +- .../details/getAndMergeAccountDetails.test.ts | 23 +- .../details/getAndMergeAccountDetails.ts | 14 +- .../src/shared/account/details/getEscape.ts | 12 +- .../src/shared/account/details/getGuardian.ts | 5 +- .../account/details/getImplementation.ts | 7 +- .../src/shared/account/details/getOwner.ts | 4 +- .../multicallWithCairo0Fallback.test.ts | 7 +- .../details/multicallWithCairo0Fallback.ts | 4 +- .../shared/account/details/tryGetClassHash.ts | 6 +- .../details/updateAccountsWithNames.ts | 2 +- .../shared/account/optimisticImplUpdate.ts | 9 +- .../extension/src/shared/account/selectors.ts | 13 +- .../accountService/AccountService.test.ts | 23 +- .../service/accountService/AccountService.ts | 56 +- .../service/accountService/IAccountService.ts | 27 +- .../account/service/accountService/index.ts | 7 + .../WalletAccountSharedService.test.ts | 140 +- .../WalletAccountSharedService.ts | 125 +- .../shared/account/store/serialize.test.ts | 8 +- .../src/shared/account/store/session.ts | 2 +- .../src/shared/account/storeMigration.ts | 3 +- .../extension/src/shared/account/update.ts | 15 +- .../extension/src/shared/account/utils.ts | 12 + .../src/shared/accountImport/account.ts | 60 + .../accountImport/pkManager/IPKManager.ts | 13 + .../accountImport/pkManager/PKManager.test.ts | 128 + .../accountImport/pkManager/PKManager.ts | 98 + .../shared/accountImport/pkManager/index.ts | 14 + .../shared/accountImport/pkManager/storage.ts | 18 + .../AccountImportSharedService.test.ts | 354 + .../service/AccountImportSharedService.ts | 255 + .../service/IAccountImportSharedService.ts | 16 + .../src/shared/accountImport/service/index.ts | 10 + .../src/shared/accountImport/types.ts | 46 + .../extension/src/shared/actionQueue/index.ts | 2 +- .../shared/actionQueue/queue/IActionQueue.ts | 4 +- .../src/shared/actionQueue/schema.test.ts | 2 +- .../src/shared/actionQueue/schema.ts | 7 +- .../actionQueue/service/IActionService.ts | 2 +- .../extension/src/shared/actionQueue/types.ts | 12 +- .../activity/__fixtures__/activities.json | 101 + .../extension/src/shared/activity/index.ts | 5 - .../transform/__fixtures__/tokensByNetwork.ts | 2 +- .../activity/buildActivitySummary.ts | 8 +- .../dappExplorerTransaction.ts | 38 - .../explorerTransaction/dappTransaction.ts | 21 - .../fingerprintExplorerTransaction.test.ts | 2 +- .../fingerprintExplorerTransaction.ts | 2 +- .../explorerTransaction/getActualFee.test.ts | 2 +- .../explorerTransaction/getActualFee.ts | 6 +- .../explorerTransaction/getParameter.ts | 2 +- .../transformExplorerTransaction.test.ts | 179 +- .../transformExplorerTransaction.ts | 22 +- .../transformers/accountCreateTransformer.ts | 2 +- .../transformers/accountUpgradeTransformer.ts | 2 +- .../dappAlphaRoadSwapTransformer.ts | 43 - .../dappAspectBuyNFTTransformer.ts | 38 - .../dappInfluenceMintTransformer.ts | 38 - .../dappJediswapSwapTransformer.ts | 51 - .../dappMintSquareBuyNFTTransformer.ts | 37 - .../transformers/dappMySwapSwapTransformer.ts | 41 - .../transformers/dateTransformer.ts | 2 +- .../defaultDisplayNameTransformer.ts | 2 +- .../transformers/feesTransformer.ts | 2 +- .../transformers/knownDappTransformer.ts | 23 - .../transformers/knownNftTransformer.ts | 4 +- .../transformers/postSwapTransformer.ts | 2 +- .../transformers/postTransferTransformer.ts | 2 +- .../transformers/tokenApproveTransformer.ts | 4 +- .../transformers/tokenMintTransformer.ts | 4 +- .../transformers/tokenTransferTransformer.ts | 4 +- .../explorerTransaction/transformers/type.ts | 6 +- .../transform/getTokenForContractAddress.ts | 2 +- .../transform/getTransactionFailureReason.ts | 2 +- .../transaction/getCallsFromTransaction.ts | 4 +- .../getTransactionSubtitle.test.ts | 8 +- .../transaction/getTransactionSubtitle.ts | 11 +- .../transaction/transformTransaction.test.ts | 46 +- .../transaction/transformTransaction.ts | 13 +- .../changeMultisigThresholdTransformer.ts | 4 +- .../transformers/changeMultisigTransformer.ts | 4 +- .../transformers/dateTransformer.ts | 2 +- .../declareContractTransformer.ts | 4 +- .../defaultDisplayNameTransformer.ts | 2 +- .../transformers/deployContractTransformer.ts | 4 +- .../transformers/guardianTransformer.ts | 4 +- .../transformers/knownDappTransformer.ts | 20 - .../transformers/nftTransferTransformer.ts | 4 +- .../onChainRejectTransformer.test.ts | 6 +- .../transformers/onChainRejectTransformer.ts | 4 +- .../pendingMultisigTransactionAdapter.ts | 6 +- .../transformers/postTransferTransformer.ts | 2 +- .../transformers/tokenMintTransformer.ts | 4 +- .../transformers/tokenTransferTransformer.ts | 4 +- .../transaction/transformers/type.ts | 6 +- .../upgradeAccountTransformer.test.ts | 21 +- .../transformers/upgradeAccountTransformer.ts | 4 +- .../shared/activity/utils/transform/type.ts | 15 +- .../addressBook/service/AddressBookService.ts | 3 +- .../extension/src/shared/addressBook/type.ts | 4 +- .../src/shared/analytics/AnalyticsService.ts | 9 +- .../extension/src/shared/analytics/init.ts | 2 +- .../extension/src/shared/api/constants.ts | 35 +- packages/extension/src/shared/api/fetcher.ts | 2 +- .../argentAccount/IArgentAccountService.ts | 2 +- packages/extension/src/shared/browser.ts | 2 +- .../src/shared/call/changeGuardianCall.ts | 5 +- .../call/changeMultisigSignersCall.test.ts | 78 + .../shared/call/changeMultisigSignersCall.ts | 46 +- .../src/shared/call/erc20ApproveCall.ts | 7 +- .../extension/src/shared/call/erc20Call.ts | 11 +- .../src/shared/call/erc20MintCall.ts | 7 +- .../src/shared/call/erc20TransferCall.test.ts | 4 +- .../src/shared/call/erc20TransferCall.ts | 7 +- .../src/shared/call/nftTransferCall.test.ts | 3 +- .../src/shared/call/nftTransferCall.ts | 5 +- .../src/shared/call/rejectOnChainCall.test.ts | 2 +- .../src/shared/call/rejectOnChainCall.ts | 17 +- .../shared/call/setMultisigThresholdCalls.ts | 5 +- .../src/shared/call/udcDeclareCall.ts | 5 +- .../src/shared/call/udcDeployCall.ts | 5 +- .../src/shared/call/upgradeAccountCall.ts | 8 +- .../src/shared/chain/service/IChainService.ts | 2 +- .../service/StarknetChainService.test.ts | 6 +- .../chain/service/StarknetChainService.ts | 6 +- .../src/shared/chain/service/__test__/mock.ts | 4 +- packages/extension/src/shared/config.ts | 4 +- .../src/shared/debounce/DebounceService.ts | 6 +- .../src/shared/debounce/IDebounceService.ts | 2 +- .../extension/src/shared/debounce/index.ts | 2 +- .../extension/src/shared/debounce/mock.ts | 2 +- .../collateralizedDebtPositions.ts | 148 + .../concentratedLiquidityPositions.ts | 78 + .../__fixtures__/defiDecomposition.ts | 255 + .../__fixtures__/delegatedTokensPositions.ts | 32 + .../parsedDefiDecompositionWithUsdValue.ts | 516 + .../__fixtures__/stakingPositions.ts | 25 + .../strkDelegatedStakingPositions.ts | 30 + .../__fixtures__/tokenPrices.ts | 84 + .../defiDecomposition/__fixtures__/tokens.ts | 121 + .../__fixtures__/tokensInfo.ts | 168 + .../getPositionTokenBalance.ts | 15 + ...ollateralizedDebtPositionsUsdValue.test.ts | 116 + ...puteCollateralizedDebtPositionsUsdValue.ts | 167 + ...entratedLiquidityPositionsUsdValue.test.ts | 92 + ...eConcentratedLiquidityPositionsUsdValue.ts | 72 + .../computeDefiDecompositionUsdValue.test.ts | 134 + .../computeDefiDecompositionUsdValue.ts | 151 + ...teDelegatedTokensPositionsUsdValue.test.ts | 82 + ...computeDelegatedTokensPositionsUsdValue.ts | 61 + .../computeStakingPositionsUsdValue.test.ts | 73 + .../computeStakingPositionsUsdValue.ts | 62 + ...kDelegatedStakingPositionsUsdValue.test.ts | 75 + ...teStrkDelegatedStakingPositionsUsdValue.ts | 62 + .../helpers/computeUsdValueForPosition.ts | 29 + .../helpers/getDefiProductName.ts | 12 + .../parseCollateralizedDebtPositions.test.ts | 69 + .../parseCollateralizedDebtPositions.ts | 110 + ...arseConcentratedLiquidityPositions.test.ts | 55 + .../parseConcentratedLiquidityPositions.ts | 102 + .../helpers/parseDefiDecomposition.test.ts | 62 + .../helpers/parseDefiDecomposition.ts | 102 + .../parseDelegatedTokensPositions.test.ts | 50 + .../helpers/parseDelegatedTokensPositions.ts | 62 + .../helpers/parseStakingPositions.test.ts | 37 + .../helpers/parseStakingPositions.ts | 58 + ...parseStrkDelegatedStakingPositions.test.ts | 47 + .../parseStrkDelegatedStakingPositions.ts | 72 + .../helpers/sortDescendingByUsdValue.ts | 19 + .../src/shared/defiDecomposition/index.ts | 5 + .../src/shared/defiDecomposition/schema.ts | 361 + packages/extension/src/shared/dev/store.ts | 11 + packages/extension/src/shared/dev/types.ts | 5 + .../src/shared/devnet/mintFeeToken.ts | 7 +- .../src/shared/discover/IDiscoverStorage.ts | 2 +- .../extension/src/shared/errors/account.ts | 6 +- .../src/shared/errors/accountMessaging.ts | 3 +- .../extension/src/shared/errors/action.ts | 3 +- .../extension/src/shared/errors/activity.ts | 3 +- .../src/shared/errors/addressBook.ts | 3 +- .../src/shared/errors/argentAccount.ts | 3 +- .../extension/src/shared/errors/errorData.ts | 2 +- .../extension/src/shared/errors/ledger.ts | 4 +- .../extension/src/shared/errors/multisig.ts | 3 +- .../extension/src/shared/errors/network.ts | 3 +- .../extension/src/shared/errors/pubKey.ts | 3 +- .../extension/src/shared/errors/recovery.ts | 3 +- .../extension/src/shared/errors/review.ts | 4 +- .../src/shared/errors/riskAssessment.ts | 3 +- .../extension/src/shared/errors/session.ts | 3 +- packages/extension/src/shared/errors/swap.ts | 3 +- packages/extension/src/shared/errors/token.ts | 4 +- .../src/shared/errors/transaction.ts | 3 +- packages/extension/src/shared/errors/udc.ts | 3 +- .../extension/src/shared/errors/wallet.ts | 3 +- .../extension/src/shared/explorer/type.ts | 2 +- .../src/shared/extensionMessenger.ts | 4 +- .../shared/feeToken/repository/preference.ts | 2 +- .../feeToken/service/FeeTokenService.test.ts | 49 +- .../feeToken/service/FeeTokenService.ts | 34 +- .../feeToken/service/IFeeTokenService.ts | 6 +- .../src/shared/feeToken/utils.test.ts | 4 +- .../extension/src/shared/feeToken/utils.ts | 5 +- packages/extension/src/shared/idb/argentDb.ts | 3 + packages/extension/src/shared/idb/db.test.ts | 151 +- packages/extension/src/shared/idb/db.ts | 35 +- .../addressNormalizerMiddleware.ts | 5 +- .../middleware/hideSpamTokensMiddleware.ts | 75 + .../src/shared/idb/migration.test.ts | 347 +- packages/extension/src/shared/idb/schema.ts | 115 +- .../shared/idb/{ => utils}/chunkedBulkPut.ts | 0 .../src/shared/idb/utils/deduplicateTable.ts | 61 + .../shared/investments/IInvestmentService.ts | 11 + .../extension/src/shared/investments/types.ts | 10 + packages/extension/src/shared/knownDapps.ts | 61 - .../shared/knownDapps/IKnownDappService.ts | 2 +- .../src/shared/knownDapps/KnownDappService.ts | 8 + .../src/shared/knownDapps/storage.ts | 4 +- .../extension/src/shared/ledger/schema.ts | 20 +- .../ledger/service/ILedgerSharedService.ts | 8 +- .../ledger/service/LedgerSharedService.ts | 27 +- .../src/shared/messages/AccountMessage.ts | 2 +- .../src/shared/messages/ActionMessage.ts | 2 +- .../src/shared/messages/NetworkMessage.ts | 4 +- .../messages/PreAuthorisationMessage.ts | 2 +- .../src/shared/messages/TokenMessage.ts | 2 +- .../src/shared/messages/TransactionMessage.ts | 54 +- .../src/shared/messages/UdcMessage.ts | 2 +- .../shared/messages/getOriginFromSender.ts | 2 +- .../src/shared/messages/isLocalhost.ts | 8 + .../src/shared/multicall/getMulticall.ts | 8 +- .../extension/src/shared/multisig/account.ts | 66 +- .../pendingOffchainSignaturesStore.ts | 15 +- .../multisig/pendingTransactionsStore.ts | 35 +- .../src/shared/multisig/repository.ts | 6 +- .../backend/IMultisigBackendService.ts | 10 +- .../backend/MultisigBackendService.test.ts | 43 +- .../service/backend/MultisigBackendService.ts | 53 +- .../shared/multisig/service/backend/types.ts | 25 +- .../service/messaging/IMultisigService.ts | 6 +- .../extension/src/shared/multisig/signer.ts | 8 +- .../extension/src/shared/multisig/types.ts | 7 +- .../src/shared/multisig/utils/baseMultisig.ts | 21 +- .../multisig/utils/getMultisigDiscoveryUrl.ts | 2 +- .../utils/getMultisigTransactionType.test.ts | 2 +- .../utils/getMultisigTransactionType.ts | 4 +- .../src/shared/multisig/utils/multisigTxV3.ts | 4 +- .../shared/multisig/utils/pendingMultisig.ts | 25 +- .../src/shared/multisig/utils/selectors.ts | 13 +- .../network/FallbackRpcProvider.test.ts | 4 +- .../src/shared/network/FallbackRpcProvider.ts | 8 +- .../shared/network/FallbackRpcProvider5.ts | 8 +- .../extension/src/shared/network/constants.ts | 7 +- .../extension/src/shared/network/defaults.ts | 8 +- .../extension/src/shared/network/index.ts | 2 +- .../shared/network/makeSafeNetworks.test.ts | 2 +- .../src/shared/network/makeSafeNetworks.ts | 2 +- .../extension/src/shared/network/provider.ts | 45 +- .../extension/src/shared/network/schema.ts | 6 + .../extension/src/shared/network/selectors.ts | 6 +- .../shared/network/service/INetworkService.ts | 4 +- .../shared/network/service/NetworkService.ts | 6 +- packages/extension/src/shared/network/type.ts | 4 +- .../extension/src/shared/nft/INFTService.ts | 8 +- .../src/shared/nft/NFTService.test.ts | 9 +- .../extension/src/shared/nft/NFTService.ts | 10 +- packages/extension/src/shared/nft/index.ts | 11 +- .../nft/marketplaces/ekuboMarketplace.test.ts | 2 +- .../nft/marketplaces/ekuboMarketplace.ts | 5 +- .../src/shared/nft/marketplaces/index.ts | 2 +- .../src/shared/nft/marketplaces/types.ts | 2 +- packages/extension/src/shared/nft/store.ts | 3 +- .../src/shared/onRamp/IOnRampService.ts | 2 +- .../PreAuthorizationService.test.ts | 4 + .../extension/src/shared/recovery/storage.ts | 2 +- .../riskAssessment/IRiskAssessmentService.ts | 2 +- .../schedule/ChromeScheduleService.test.ts | 5 +- .../shared/schedule/ChromeScheduleService.ts | 4 +- .../extension/src/shared/schedule/mock.ts | 5 +- .../extension/src/shared/send/schema.test.ts | 51 - packages/extension/src/shared/send/schema.ts | 14 - packages/extension/src/shared/sentry/types.ts | 2 +- .../src/shared/sessionKeys/schema.ts | 2 +- .../src/shared/sessionKeys/whitelist.ts | 11 +- .../extension/src/shared/settings/store.ts | 5 +- .../extension/src/shared/settings/types.ts | 2 +- .../src/shared/signer/ArgentSigner.ts | 9 +- .../src/shared/signer/BaseSignerInterface.ts | 3 +- .../src/shared/signer/GuardianSignerV2.ts | 24 +- .../src/shared/signer/LedgerSigner.ts | 218 +- .../src/shared/signer/PrivateKeySigner.ts | 44 + .../src/shared/signer/derivationPaths.ts | 21 +- packages/extension/src/shared/signer/utils.ts | 45 +- .../shared/smartAccount/GuardianSelfSigner.ts | 6 +- .../smartAccount/ISmartAccountService.ts | 2 +- .../smartAccount/SmartAccountService.ts | 11 +- .../src/shared/smartAccount/account.ts | 24 +- .../shared/smartAccount/backend/account.ts | 33 +- .../changeGuardianCallDataToType.ts | 10 +- .../smartAccount/getChangeGuardianCalldata.ts | 2 +- .../src/shared/smartAccount/index.ts | 2 +- .../extension/src/shared/smartAccount/jwt.ts | 2 +- .../src/shared/smartAccount/jwtFetcher.ts | 4 +- .../validation/addBackendAccount.ts | 11 +- .../validation/validateAccount.test.ts | 5 +- .../validation/validateAccount.ts | 6 +- .../src/shared/staking/IStakingService.ts | 17 + .../extension/src/shared/staking/storage.ts | 14 + .../extension/src/shared/staking/types.ts | 17 + .../extension/src/shared/staking/utils.ts | 22 + .../src/shared/starknetAccount/base.ts | 76 +- .../src/shared/starknetAccount/index.ts | 19 +- .../src/shared/starknetAccount/types.ts | 2 +- .../src/shared/statusMessage/storage.ts | 2 +- .../src/shared/statusMessage/types.ts | 2 +- .../__new/__test__/__fixtures__/storage.json | 5363 ++++++ .../__new/__test__/inmemoryImplementations.ts | 4 +- .../storage/__new/__test__/keyvalue.test.ts | 4 +- .../__test__/mockFunctionImplementation.ts | 2 +- .../storage/__new/__test__/object.test.ts | 2 +- .../src/shared/storage/__new/chrome.ts | 2 +- .../src/shared/storage/__new/keyvalue.ts | 4 +- .../src/shared/storage/__new/object.ts | 6 +- .../src/shared/storage/__new/prune.test.ts | 3 +- .../src/shared/storage/__new/prune.ts | 6 +- .../__new/replaceValueInStorage.test.ts | 90 + .../storage/__new/replaceValueInStorage.ts | 62 + .../src/shared/storage/__new/repository.ts | 4 +- .../src/shared/storage/__new/utils.test.ts | 119 + .../src/shared/storage/__new/utils.ts | 55 + .../storage/__test__/chrome-storage.mock.ts | 5 +- .../shared/storage/__test__/keyvalue.test.ts | 5 +- .../shared/storage/__test__/object.test.ts | 3 +- .../extension/src/shared/storage/array.ts | 29 +- .../extension/src/shared/storage/keyvalue.ts | 7 +- .../extension/src/shared/storage/object.ts | 4 +- .../extension/src/shared/storage/options.ts | 2 +- .../extension/src/shared/storage/types.ts | 2 +- .../shared/swap/service/ISharedSwapService.ts | 9 +- .../swap/service/SharedSwapService.test.ts | 2 +- .../shared/swap/service/SharedSwapService.ts | 39 +- .../src/shared/swap/utils/totalFee.ts | 4 +- packages/extension/src/shared/test.utils.ts | 152 + .../src/shared/token/__deprecated/storage.ts | 3 +- .../src/shared/token/__deprecated/type.ts | 1 + .../src/shared/token/__deprecated/utils.ts | 2 +- .../__fixtures__/mockTokensWithBalance.ts | 2 +- .../src/shared/token/__new/constants.ts | 2 +- .../token/__new/service/ITokenService.ts | 27 +- .../token/__new/service/TokenService.test.ts | 269 +- .../token/__new/service/TokenService.ts | 187 +- .../src/shared/token/__new/service/index.ts | 4 +- .../shared/token/__new/types/token.model.ts | 2 + .../token/__new/types/tokenInfo.model.ts | 2 +- .../utils/decodeShortStringArray.test.ts | 28 + .../__new/utils/decodeShortStringArray.ts | 8 + .../src/shared/token/__new/utils/index.ts | 8 +- .../src/shared/token/prettifyTokenBalance.ts | 5 +- .../src/shared/tokenDetails/interface.ts | 38 + .../shared/transactionReview.service.test.ts | 161 +- .../src/shared/transactionReview.service.ts | 64 +- .../src/shared/transactionReview/interface.ts | 45 +- .../src/shared/transactionReview/store.ts | 2 +- .../transactionAction.model.ts | 64 + .../fees/estimatedFeesRepository.ts | 7 +- .../transactionSimulation/fees/fees.model.ts | 4 +- .../findTransferAndApproval.ts | 4 +- .../transactionSimulation.service.ts | 58 - .../src/shared/transactionSimulation/types.ts | 12 +- packages/extension/src/shared/transactions.ts | 13 +- .../getChangedStatusTransactions.test.ts | 3 +- .../getChangedStatusTransactions.ts | 2 +- .../src/shared/transactions/interface.ts | 2 +- .../src/shared/transactions/store.ts | 8 +- .../transactionHashesRepository.ts | 45 + .../src/shared/transactions/utils.test.ts | 4 +- .../src/shared/transactions/utils.ts | 8 +- .../extension/src/shared/types/deepPick.ts | 28 +- packages/extension/src/shared/udc/schema.ts | 12 +- packages/extension/src/shared/udc/store.ts | 3 +- .../extension/src/shared/ui/UIService.test.ts | 7 +- packages/extension/src/shared/ui/UIService.ts | 7 +- packages/extension/src/shared/ui/constants.ts | 2 + packages/extension/src/shared/ui/routes.ts | 262 +- .../shared/utils/accountIdentifier.test.ts | 129 + .../src/shared/utils/accountIdentifier.ts | 50 + .../src/shared/utils/accountsEqual.test.ts | 133 + .../src/shared/utils/accountsEqual.ts | 86 +- .../src/shared/utils/accountsMultisigSort.ts | 21 +- .../src/shared/utils/argentAccountVersion.ts | 14 +- .../src/shared/utils/derivationPath.ts | 9 +- packages/extension/src/shared/utils/error.ts | 2 +- .../src/shared/utils/getActiveFromNow.ts | 28 + .../src/shared/utils/getContractAddress.ts | 6 +- .../src/shared/utils/isExternalAccount.ts | 53 + .../utils/isSafeUpgradeTransaction.test.ts | 27 +- .../shared/utils/isSafeUpgradeTransaction.ts | 4 +- packages/extension/src/shared/utils/object.ts | 4 +- .../shared/utils/sanitizeAccountType.test.ts | 2 +- .../src/shared/utils/sanitizeAccountType.ts | 4 +- .../src/shared/utils/starknetNetwork.ts | 2 +- .../src/shared/utils/transactionSucceeded.ts | 2 +- .../src/shared/utils/transactions.ts | 2 +- .../extension/src/shared/utils/url.test.ts | 93 + packages/extension/src/shared/utils/url.ts | 37 +- .../utils/validateSignatureChainId.test.ts | 53 + .../shared/utils/validateSignatureChainId.ts | 34 + packages/extension/src/shared/wallet.model.ts | 53 +- .../extension/src/shared/wallet.service.ts | 9 +- .../shared/wallet/getDefaultSortedAccount.ts | 2 +- .../wallet/getDefaultSortedAccounts.test.ts | 2 +- .../src/shared/wallet/walletStore.ts | 11 +- packages/extension/src/ui/App.tsx | 74 +- .../extension/src/ui/AppBackgroundError.tsx | 12 +- .../src/ui/AppErrorBoundaryFallback.tsx | 19 +- packages/extension/src/ui/AppRoutes.tsx | 285 +- .../src/ui/components/ActionButton.tsx | 45 + .../src/ui/components/AddressCopyButton.tsx | 17 +- .../extension/src/ui/components/BackLink.tsx | 10 - .../src/ui/components/BottomSheet.tsx | 89 - .../extension/src/ui/components/Button.tsx | 317 - .../src/ui/components/ClearStorageModal.tsx | 8 +- .../extension/src/ui/components/Column.tsx | 36 - .../src/ui/components/ControlledInput.tsx | 8 +- .../src/ui/components/ControlledPinInput.tsx | 8 +- .../src/ui/components/ControlledTextArea.tsx | 35 - .../src/ui/components/CopyIconButton.tsx | 53 - .../src/ui/components/CopyTooltip.tsx | 63 - .../src/ui/components/CustomButtonCell.tsx | 5 +- .../src/ui/components/DisclosureIcon.tsx | 22 - .../src/ui/components/ErrorBoundary.tsx | 3 +- .../ui/components/ErrorBoundaryFallback.tsx | 30 - .../ErrorBoundaryFallbackWithCopyError.tsx | 328 +- .../src/ui/components/ErrorScreen.tsx | 8 +- .../extension/src/ui/components/Fields.tsx | 77 - .../src/ui/components/FullScreenPage.tsx | 38 +- .../extension/src/ui/components/Header.tsx | 17 - .../extension/src/ui/components/IOSSwitch.tsx | 76 - .../extension/src/ui/components/IconBar.tsx | 73 - .../src/ui/components/IconButton.tsx | 25 - .../src/ui/components/Icons/AlertIcon.tsx | 29 - .../src/ui/components/Icons/ArgentXBanner.tsx | 67 - .../src/ui/components/Icons/ArgentXIcon.tsx | 31 - .../src/ui/components/Icons/ArgentXLogo.tsx | 1 - .../src/ui/components/Icons/AspectLogo.tsx | 21 - .../src/ui/components/Icons/BackIcon.tsx | 25 - .../src/ui/components/Icons/CheckIcon.tsx | 21 - .../src/ui/components/Icons/ChevronDown.tsx | 28 - .../src/ui/components/Icons/ChevronRight.tsx | 18 - .../src/ui/components/Icons/CloseIcon.tsx | 18 - .../src/ui/components/Icons/CloseIconAlt.tsx | 19 - .../src/ui/components/Icons/DangerIcon.tsx | 18 - .../src/ui/components/Icons/DiscordIcon.tsx | 19 - .../src/ui/components/Icons/EditIcon.tsx | 38 - .../src/ui/components/Icons/HeartFilled.tsx | 27 - .../src/ui/components/Icons/InfoCircle.tsx | 29 - .../ui/components/Icons/MintSquareLogo.tsx | 44 - .../src/ui/components/Icons/MuiIcons.ts | 39 - .../components/Icons/NetworkWarningIcon.tsx | 16 - .../src/ui/components/Icons/PluginIcon.tsx | 19 - .../src/ui/components/Icons/PlusCircle.tsx | 32 - .../ui/components/Icons/RocketLaunchIcon.tsx | 27 - .../src/ui/components/Icons/SearchIcon.tsx | 19 - .../ui/components/Icons/SessionPluginIcon.tsx | 48 - .../src/ui/components/Icons/StarknetIcon.tsx | 39 - .../src/ui/components/Icons/SupportIcon.tsx | 19 - .../src/ui/components/Icons/UpdateIcon.tsx | 23 - .../Icons/ViewOnBlockExplorerIcon.tsx | 34 - .../src/ui/components/Icons/WarningIcon.tsx | 19 - .../components/Icons/WarningIconRounded.tsx | 19 - .../ui/components/Icons/svg/argent-x-logo.svg | 8 - .../src/ui/components/InputSelect.tsx | 192 - .../extension/src/ui/components/InputText.tsx | 420 - packages/extension/src/ui/components/Menu.tsx | 55 - .../src/ui/components/ModalSheet.tsx | 11 +- .../src/ui/components/Notification.tsx | 16 +- .../extension/src/ui/components/Option.tsx | 72 + .../extension/src/ui/components/Options.tsx | 66 - packages/extension/src/ui/components/Page.tsx | 23 - .../extension/src/ui/components/QrCode.tsx | 13 +- .../src/ui/components/Responsive.tsx | 44 +- packages/extension/src/ui/components/Row.tsx | 52 - .../src/ui/components/ScreenSkeleton.tsx | 5 +- .../src/ui/components/ShortAddressBadge.tsx | 74 - .../extension/src/ui/components/Spinner.tsx | 25 - .../ui/components/StarknetIdCopyButton.tsx | 4 +- .../StarknetIdOrAddressCopyButton.tsx | 6 +- .../src/ui/components/StatusIndicator.tsx | 16 +- .../src/ui/components/StepIndicator.tsx | 25 +- .../src/ui/components/SuspenseScreen.tsx | 6 +- .../src/ui/components/TokenOption.tsx | 22 +- .../src/ui/components/TrackingLink.tsx | 70 +- .../accountActivity/AccountActivity.tsx | 132 - .../AccountActivityContainer.tsx | 332 +- .../ActivityDetailsScreen.tsx | 13 +- .../ActivityDetailsScreenContainer.tsx | 5 +- .../ActivityDetailsScreenEmpty.tsx | 10 +- .../ActivityHistoryContainer.tsx | 25 + .../ActivityListContainer.tsx} | 45 +- .../accountActivity/EmptyAccountActivity.tsx | 10 + .../MultisigAccountActivityContainer.tsx | 35 +- .../accountActivity/PendingTransactions.tsx | 70 - .../accountActivity/TransactionDetail.tsx | 522 - .../TransactionDetailScreen.tsx | 140 - .../TransactionDetailWrapper.tsx | 40 - .../TransactionListErrorItem.tsx | 30 - .../accountActivity/TransactionListItem.tsx | 193 - .../OffchainSignatureListItem.tsx | 27 +- .../PendingMultisigTransactions.tsx | 64 +- .../legacy/ui/TransactionIcon.tsx | 45 + .../state.ts | 2 +- .../ui/ExpandableFieldGroup.tsx | 83 - .../accountActivity/ui/LoadMoreTrigger.tsx | 57 - .../features/accountActivity/ui/NFTImage.tsx | 27 - .../features/accountActivity/ui/NFTTitle.tsx | 40 - .../accountActivity/ui/SwapAccessory.tsx | 63 - .../ui/SwapTransactionIcon.tsx | 73 - .../ui/TransactionCallDataBottomSheet.tsx | 60 - .../accountActivity/ui/TransactionIcon.tsx | 183 - .../accountActivity/ui/TransferAccessory.tsx | 62 - .../accountActivity/ui/TransferTitle.tsx | 46 - .../useActivityTabWithRestoreScrollState.ts | 24 + .../accountActivity/useArgentExplorer.ts | 147 - .../useTransactionFees.test.ts | 48 - .../accountActivity/useTransactionFees.ts | 41 - .../accountActivity/useTransactionNonce.ts | 22 - .../AccountActivityContainerV2.tsx | 26 - .../EmptyAccountActivity.tsx | 7 - .../accountNfts/AccountCollection.tsx | 59 +- .../accountNfts/AccountCollections.tsx | 24 +- .../AccountCollectionsContainer.tsx | 46 +- .../features/accountNfts/CollectionNfts.tsx | 56 +- .../accountNfts/CollectionNftsContainer.tsx | 11 +- .../CollectionNftsGenericError.tsx | 22 +- .../features/accountNfts/EmptyCollections.tsx | 45 +- .../ui/features/accountNfts/NftFallback.tsx | 6 +- .../src/ui/features/accountNfts/NftFigure.tsx | 16 +- .../src/ui/features/accountNfts/NftImage.tsx | 10 +- .../src/ui/features/accountNfts/NftItem.tsx | 70 +- .../features/accountNfts/NftModelViewer.tsx | 5 +- .../src/ui/features/accountNfts/NftScreen.tsx | 31 +- .../accountNfts/NftScreenContainer.tsx | 11 +- .../accountNfts/NftThumbnailImage.tsx | 29 - .../accountNfts/WalletNftsTabContainer.tsx | 41 + .../src/ui/features/accountNfts/nfts.state.ts | 2 +- .../ui/features/accountNfts/useRemoteNft.ts | 6 +- .../features/accountTokens/AccountBanners.tsx | 67 - .../accountTokens/AccountBannersContainer.tsx | 54 - .../features/accountTokens/AccountTokens.tsx | 126 +- .../accountTokens/AccountTokensBalance.tsx | 38 +- .../AccountTokensBalanceContainer.tsx | 8 +- .../AccountTokensButtons.test.tsx | 24 +- .../accountTokens/AccountTokensButtons.tsx | 151 +- .../AccountTokensButtonsContainer.tsx | 74 +- .../accountTokens/AccountTokensContainer.tsx | 15 +- .../accountTokens/ActivateMultisigBanner.tsx | 22 - .../HiddenAndSpamTokensScreen.tsx | 112 + .../HiddenAndSpamTokensScreenContainer.tsx | 27 + .../accountTokens/HideTokenListItem.tsx | 90 + .../accountTokens/HideTokenScreen.tsx | 7 +- .../HideTokenScreenContainer.tsx | 11 +- .../features/accountTokens/NewTokenButton.tsx | 8 +- .../accountTokens/PrettyAccountBalance.tsx | 25 - .../features/accountTokens/PrettyBalance.tsx | 53 + .../SaveRecoverySeedphraseBanner.tsx | 46 - .../ui/features/accountTokens/TokenList.tsx | 35 +- ...stContainer.tsx => TokenListContainer.tsx} | 20 +- .../features/accountTokens/TokenListItem.tsx | 28 +- .../accountTokens/TokenListItemContainer.tsx | 16 +- .../ui/features/accountTokens/TokenMenu.tsx | 28 +- .../features/accountTokens/UpgradeBanner.tsx | 72 - .../accountTokens/WalletCoinsTabContainer.tsx | 28 + .../banner/assets/airdrop@3x.png | Bin 11504 -> 0 bytes .../banner/assets/airdropHalted@3x.png | Bin 10626 -> 0 bytes .../features/accountTokens/tokenPriceHooks.ts | 12 +- .../features/accountTokens/tokens.service.ts | 2 +- .../ui/features/accountTokens/tokens.state.ts | 175 +- .../accountTokens/useAddFundsDialog.tsx | 25 +- .../useDisplayTokenAmountAndCurrencyValue.tsx | 2 +- .../accountTokens/useFeeTokenBalance.ts | 12 +- .../accountTokens/useHasNonZeroBalance.ts | 10 + .../accountTokens/useIsAccountDeploying.ts | 2 +- .../accountTokens/useMaxFeeForTransfer.ts | 68 +- .../accountTokens/usePrettyAccountBalance.ts | 17 - .../accountTokens/usePrettyBalance.ts | 35 + .../features/accountTokens/usePrivateKey.ts | 14 +- .../accountTokens/usePromptUserReview.ts | 7 +- .../accountTokens/useShowAccountUpgrade.ts | 16 +- .../useTokenBalanceForAccount.ts | 27 +- .../accountTokens/useTransactionStatus.ts | 14 +- .../warning/AccountDeprecatedBanner.tsx | 31 - .../warning/AccountDeprecatedModal.tsx | 20 +- .../warning/AccountOwnerBanner.tsx | 22 - .../warning/AccountOwnerWarningScreen.tsx | 6 +- .../src/ui/features/accounts/Account.ts | 48 +- .../accounts/AccountAddressListItem.tsx | 21 +- .../AccountAddressListItemSaveAccessory.tsx | 12 +- .../ui/features/accounts/AccountAvatar.tsx | 9 +- .../src/ui/features/accounts/AccountLabel.tsx | 12 +- .../src/ui/features/accounts/AccountList.tsx | 164 + .../accounts/AccountListContainer.tsx | 71 + .../features/accounts/AccountListFooter.tsx | 9 +- .../accounts/AccountListFooterContainer.tsx | 40 - .../accounts/AccountListHiddenScreen.test.tsx | 113 +- .../accounts/AccountListHiddenScreen.tsx | 19 +- .../AccountListHiddenScreenContainer.tsx | 14 +- .../ui/features/accounts/AccountListItem.tsx | 78 +- .../AccountListItemDeprecatedBadge.tsx | 8 +- .../accounts/AccountListItemEditAccessory.tsx | 34 + .../accounts/AccountListItemLedgerBadge.tsx | 5 +- .../accounts/AccountListItemSkeleton.tsx | 2 +- .../AccountListItemSmartAccountBadge.tsx | 14 +- ...ountListItemSmartAccountBadgeContainer.tsx | 10 +- .../accounts/AccountListItemUpgradeBadge.tsx | 8 +- .../accounts/AccountListItemWithBalance.tsx | 6 +- .../features/accounts/AccountListScreen.tsx | 129 - .../accounts/AccountListScreenContainer.tsx | 129 +- .../accounts/AccountListScreenItem.test.tsx | 1 + .../accounts/AccountListScreenItem.tsx | 8 +- .../AccountListScreenItemAccessory.tsx | 2 +- .../AccountListScreenItemContainer.tsx | 21 +- .../accounts/AccountNavigationBar.test.tsx | 34 - .../accounts/AccountNavigationBar.tsx | 87 - .../AccountNavigationBarContainer.tsx | 54 - .../accounts/AccountScreenEmpty.test.tsx | 14 +- .../features/accounts/AccountScreenEmpty.tsx | 41 +- .../accounts/AccountScreenEmptyContainer.tsx | 68 +- .../ui/features/accounts/AccountSelect.tsx | 9 +- .../accounts/AddNewAccountScreen.test.tsx | 14 +- .../features/accounts/AddNewAccountScreen.tsx | 8 +- .../accounts/AddNewAccountScreenContainer.tsx | 28 +- .../accounts/AddressBookMenu.test.tsx | 2 +- .../ui/features/accounts/AddressBookMenu.tsx | 22 +- .../accounts/ClickableSmartAccountBanner.tsx | 18 +- .../features/accounts/DeployAccountScreen.tsx | 18 +- .../accounts/DeployAccountScreenContainer.tsx | 7 +- .../accounts/DeprecatedAccountsWarning.tsx | 14 +- .../features/accounts/GroupedAccountList.tsx | 28 +- .../HideOrDeleteAccountConfirmScreen.test.tsx | 1 + .../HideOrDeleteAccountConfirmScreen.tsx | 70 +- ...eOrDeleteAccountConfirmScreenContainer.tsx | 48 +- .../accounts/PrettyAccountAddressArgentX.tsx | 20 +- .../SmartAccountDetailedDescription.tsx | 35 - .../SmartAccountDetailsDescription.tsx | 37 + ...SmartAccountDetailsDescriptionImproved.tsx | 35 - .../ui/features/accounts/WarningScreen.tsx | 22 +- .../accounts/accountListItem.model.ts | 11 +- .../accounts/accountMetadata.state.ts | 162 - .../features/accounts/accountMetadata.test.ts | 119 - .../accounts/accountNavigationBar.model.ts | 20 - .../accounts/accountTransactions.state.ts | 9 +- .../accounts/accountUpgradeCheck.test.ts | 4 +- .../features/accounts/accountUpgradeCheck.ts | 11 +- .../ui/features/accounts/accounts.service.ts | 42 +- .../ui/features/accounts/accounts.state.ts | 8 +- .../accounts/deployAccountScreen.model.ts | 2 +- .../accounts/getDefaultSortedAccount.ts | 2 +- .../accounts/getDefaultSortedAccounts.test.ts | 2 +- .../hideOrDeleteAccountConfirmScreen.model.ts | 12 - .../accounts/networkTransactions.state.ts | 2 +- .../src/ui/features/accounts/switchAccount.ts | 55 - .../features/accounts/ui/AccountTypesList.tsx | 107 +- .../accounts/ui/StarknetAccountMessage.tsx | 8 +- .../features/accounts/useAccountOrContact.ts | 2 +- .../ui/features/accounts/useAccountOwner.ts | 9 +- .../accounts/useAccountTypesForNetwork.tsx | 122 +- .../accounts/useOnSettingsAccountNavigate.ts | 2 +- .../accounts/useOnSettingsNavigate.ts | 10 +- .../accounts/usePartitionedAccountsByType.ts | 38 + .../src/ui/features/accounts/usePublicKey.ts | 43 +- .../accounts/useSortedAccounts.test.ts | 43 + .../ui/features/accounts/useSortedAccounts.ts | 16 + .../ui/features/actions/AccountAddress.tsx | 30 - ...onScreen.tsx => ActionScreenContainer.tsx} | 5 +- .../actions/AddNetworkScreen.test.tsx | 7 +- .../ui/features/actions/AddNetworkScreen.tsx | 10 +- .../actions/AddNetworkScreenContainer.tsx | 3 +- .../actions/AddTokenActionScreenContainer.tsx | 2 +- .../ui/features/actions/AddTokenScreen.tsx | 141 +- .../actions/AddTokenScreenContainer.tsx | 62 +- .../features/actions/ApproveDeployAccount.tsx | 51 +- .../actions/ApproveSignatureScreen.test.tsx | 2 +- .../actions/ApproveSignatureScreen.tsx | 16 +- .../ApproveSignatureScreenContainer.tsx | 6 +- .../DeclareContractActionScreenContainer.tsx | 14 +- .../DeployAccountActionScreenContainer.tsx | 115 +- .../DeployContractActionScreenContainer.tsx | 4 +- .../DeployMultisigActionScreenContainer.tsx | 115 +- .../src/ui/features/actions/ErrorScreen.tsx | 12 +- .../features/actions/ErrorScreenContainer.tsx | 2 +- .../actions/ExecuteFromOutsideScreen.tsx | 4 +- .../src/ui/features/actions/LoadingScreen.tsx | 2 +- .../actions/SignActionScreenContainer.tsx | 2 +- .../SignatureRequestRejectedScreen.tsx | 19 +- .../features/actions/SwitchNetworkScreen.tsx | 16 +- .../actions/SwitchNetworkScreenContainer.tsx | 15 +- .../features/actions/__fixtures__/accounts.ts | 42 +- .../features/actions/__fixtures__/aspect.ts | 58 +- .../actions/__fixtures__/dataToSign.ts | 2 +- .../features/actions/__fixtures__/jediswap.ts | 76 +- .../features/actions/__fixtures__/transfer.ts | 29 +- .../actions/__fixtures__/transferV3.ts | 29 +- .../ui/features/actions/__fixtures__/types.ts | 7 +- .../connectDapp/ConnectDappAccountSelect.tsx | 24 +- .../connectDapp/ConnectDappScreen.test.tsx | 4 +- .../actions/connectDapp/ConnectDappScreen.tsx | 26 +- .../ConnectDappScreenContainer.test.tsx | 15 +- .../ConnectDappScreenContainer.tsx | 18 +- .../actions/connectDapp/DappActionHeader.tsx | 15 +- .../features/actions/connectDapp/DappIcon.tsx | 7 +- .../actions/connectDapp/DappIconContainer.tsx | 2 +- .../connectDapp/KnownDappButtonWrapper.tsx | 2 +- .../connectDapp/useDappDisplayAttributes.ts | 55 - .../actions/connectDapp/useIsInfluenceDapp.ts | 14 + .../actions/connectDapp/useRiskAssessment.ts | 2 +- .../DeployAccountFeeEstimation.tsx | 116 - .../actions/feeEstimation/FeeEstimation.tsx | 7 +- .../__fixtures__/feeEstimation.ts | 2 +- .../feeEstimation/__fixtures__/feeToken.ts | 2 +- .../feeEstimation/feeEstimation.model.ts | 6 +- .../features/actions/feeEstimation/types.ts | 9 +- .../feeEstimation/ui/CopyErrorIcon.tsx | 8 +- .../feeEstimation/ui/FeeEstimationBox.tsx | 22 +- .../feeEstimation/ui/FeeEstimationText.tsx | 31 +- .../feeEstimation/ui/FeeTokenPickerModal.tsx | 15 +- .../ui/InsufficientFundsAccordion.tsx | 15 +- .../feeEstimation/ui/TokenOptionContainer.tsx | 16 +- .../actions/feeEstimation/ui/TokenPicker.tsx | 8 +- .../feeEstimation/ui/TokenPickerScreen.tsx | 68 - .../ui/TransactionFailureAccordion.tsx | 18 +- .../feeEstimation/ui/WaitingForFunds.tsx | 2 +- .../actions/feeEstimation/useEstimatedFees.ts | 8 - .../feeEstimation/useRequiresTxV3Upgrade.ts | 9 +- .../features/actions/feeEstimation/utils.tsx | 188 +- .../features/actions/hooks/useActionScreen.ts | 8 +- .../actions/hooks/useLedgerForTransaction.ts | 4 +- .../features/actions/hooks/usePortfolioUrl.ts | 2 +- .../AccountNetworkInfoArgentX.tsx | 25 +- .../ApproveTransactionScreen.test.tsx | 29 +- .../ApproveTransactionScreen.tsx | 132 +- .../ApproveTransactionScreenContainer.tsx | 157 +- .../ConfirmScreen.tsx | 41 +- .../DappHeader/DappHeaderArgentX.tsx | 76 - .../TransactionIcon/ActivateAccountIcon.tsx | 13 - .../TransactionIcon/ActivateMultisigIcon.tsx | 13 - .../TransactionIcon/AddOwnerIcon.tsx | 13 - .../DeclareTransactionIcon.tsx | 17 - .../DowngradeSmartAccountIcon.tsx | 13 - .../TransactionIcon/IconWrapper.tsx | 22 - .../TransactionIcon/NftTransactionIcon.tsx | 85 - .../TransactionIcon/RemoveOwnerIcon.tsx | 13 - .../TransactionIcon/ReplaceOwnerIcon.tsx | 13 - .../TransactionIcon/SendTransactionIcon.tsx | 74 - .../TransactionIcon/SwapTransactionIcon.tsx | 70 - .../TransactionIcon/UnknownDappIcon.tsx | 13 - .../TransactionIcon/UnknownTokenIcon.tsx | 13 - .../TransactionIcon/UpdateThresholdIcon.tsx | 13 - .../UpgradeSmartAccountIcon.tsx | 13 - .../TransactionIcon/VerifiedDappIcon.tsx | 9 - .../DappHeader/TransactionIcon/index.test.tsx | 137 - .../DappHeader/TransactionIcon/index.tsx | 109 - .../DappHeader/TransactionTitleArgentX.tsx | 204 - .../TransactionActions.tsx | 129 - .../WithActionScreenErrorFooter.tsx | 2 +- .../approveTransactionScreen.model.ts | 26 +- .../ledger/LedgerActionModal.tsx | 31 +- .../ledger/LedgerModalBottomDialog.tsx | 93 +- .../MultisigConfirmationsBanner.tsx | 18 +- .../transaction/airgap/AirGapReviewButton.tsx | 22 +- .../transaction/airgap/AirGapReviewScreen.tsx | 27 +- .../useValidateOutsideExecution.test.ts | 6 +- .../useValidateOutsideExecution.ts | 4 +- .../transaction/executeFromOutside/utils.ts | 2 +- .../fields/AccountAddressField.tsx | 25 - .../transaction/fields/ContractField.tsx | 30 - .../transaction/fields/DappContractField.tsx | 40 - .../actions/transaction/fields/FeeField.tsx | 41 - .../fields/MaybeDappContractField.tsx | 18 - .../transaction/fields/ParameterField.tsx | 71 - .../actions/transaction/fields/TokenField.tsx | 51 - .../transaction/getKnownWalletAddress.ts | 4 +- .../transaction/getTransactionIcon.test.ts | 92 + .../transaction/getTransactionIcon.tsx | 62 + .../transaction/getTransactionTitle.test.ts | 106 + .../transaction/getTransactionTitle.tsx | 99 + .../ui/features/actions/transaction/types.ts | 30 - .../actions/transaction/useErc721Transfers.ts | 25 - .../useTransactionSimulatedData.ts | 482 - .../transaction/useTransactionSimulation.ts | 73 - .../transactionV2/AmountEditModalForm.tsx | 25 +- ...ainerV2.tsx => FeeEstimationContainer.tsx} | 35 +- .../actions/transactionV2/ReviewFallback.tsx | 21 +- .../transactionV2/SessionKeyReview.tsx | 41 +- .../SignActionScreenContainerV2.tsx | 39 +- .../transactionV2/SignActionScreenV2.tsx | 35 +- .../TransactionActionScreenContainer.tsx | 302 +- .../TransactionActionScreenSkeleton.tsx | 73 + .../AccountDetailsNavigationContainer.tsx | 28 - .../header/NavigationBarAccountDetails.tsx | 85 - .../NavigationBarAccountDetailsContainer.tsx | 26 - .../transactionV2/header/SessionKeyHeader.tsx | 21 +- .../header/TransactionHeader.tsx | 13 +- .../transactionV2/header/TransactionTitle.tsx | 10 +- .../transactionV2/header/icon/DappIcon.tsx | 3 +- .../transactionV2/header/icon/IconWrapper.tsx | 13 +- .../transactionV2/header/icon/KnownIcon.tsx | 12 +- .../header/icon/TransactionIcon.tsx | 12 +- .../header/icon/UnknownDappIcon.tsx | 12 +- .../actions/transactionV2/header/index.ts | 2 - .../actions/transactionV2/useAirGapData.ts | 15 +- .../useFeeTokenSelection.test.ts | 2 +- .../transactionV2/useFeeTokenSelection.ts | 11 +- .../transactionV2/useTransactionHash.ts | 39 - .../useTransactionReviewV2.test.ts | 13 +- .../transactionV2/useTransactionReviewV2.ts | 70 +- .../transactionV2/useTxnsHasV3Upgrade.ts | 2 +- .../utils/getAmpliPayloadFromReview.ts | 6 +- .../utils/hasFeeTokenEnoughBalance.ts | 5 +- .../utils/parseTransferTokenCall.test.ts | 3 +- .../utils/parseTransferTokenCall.ts | 6 +- .../ui/features/actions/useDefaultFeeToken.ts | 6 +- .../src/ui/features/actions/utils.test.ts | 2 +- .../src/ui/features/actions/utils.ts | 16 +- .../actions/warning/ConfirmationModal.tsx | 12 +- .../features/actions/warning/ReviewFooter.tsx | 6 +- .../actions/warning/WarningBanner.tsx | 4 +- .../features/actions/warning/WarningModal.tsx | 32 +- .../actions/warning/WarningModalContainer.tsx | 10 +- .../features/actions/warning/helper.test.ts | 4 +- .../src/ui/features/actions/warning/helper.ts | 4 +- .../ui/features/actions/warning/warningMap.ts | 4 +- .../ArgentAccountBaseEmailScreen.tsx | 20 +- .../ArgentAccountEmailScreen.tsx | 13 +- .../ArgentAccountFeaturesList.tsx | 10 +- .../ArgentAccountLoggedInScreen.tsx | 18 +- .../ArgentAccountLoggedInScreenContainer.tsx | 2 +- .../argentAccountBaseEmailScreen.model.ts | 2 +- .../ui/features/banners/AccountBanners.tsx | 50 + .../banners/AccountBannersContainer.tsx | 16 + .../banners/AccountDeprecatedBanner.tsx | 41 + .../features/banners/AccountOwnerBanner.tsx | 38 + .../src/ui/features/banners/Banner.tsx | 137 + .../src/ui/features/banners/EscapeBanner.tsx | 113 + .../banners/EscapeBannerContainer.tsx | 39 + .../ui/features/banners/MultisigBanner.tsx | 48 + .../banners/MultisigBannerContainer.tsx | 53 + .../features/banners/PromoStakingBanner.tsx | 26 + .../banners/PromoStakingBannerContainer.tsx | 57 + .../banners/SaveRecoverySeedphraseBanner.tsx | 64 + .../features/banners/StatusMessageBanner.tsx | 36 + .../banners/StatusMessageBannerContainer.tsx | 35 + .../src/ui/features/banners/UpgradeBanner.tsx | 35 + .../UpgradeBannerContainer.tsx | 63 +- .../src/ui/features/banners/useBanners.tsx | 101 + .../extension/src/ui/features/browser/tabs.ts | 6 +- .../src/ui/features/defi/TokenInfo.tsx | 99 + .../features/defi/WalletDefiTabContainer.tsx | 74 + .../CollateralizedDebtStatus.tsx | 54 + .../ConcentratedLiquidityStatus.test.ts | 80 + .../ConcentratedLiquidityStatus.tsx | 87 + .../defiDecomposition/DefiDecomposition.tsx | 113 + .../DefiDecompositionContainer.tsx | 78 + .../defi/defiDecomposition/DefiIcon.tsx | 240 + .../defi/defiDecomposition/DefiPosition.tsx | 73 + .../defiDecomposition/DefiPositionBalance.tsx | 77 + .../DefiPositionDescription.tsx | 30 + .../DefiPositionSkeleton.tsx | 32 + .../DefiPositionSubtitle.tsx | 14 + .../defiDecomposition/DefiPositionTitle.tsx | 7 + .../StrkDelegatedStakingStatus.tsx | 54 + .../DefiPositionAlertBanner.tsx | 150 + .../DefiPositionDetailsActions.tsx | 183 + .../DefiPositionDetailsBalance.tsx | 19 + .../DefiPositionDetailsBreakdownInfo.tsx | 235 + .../DefiPositionDetailsScreen.tsx | 192 + .../DefiPositionDetailsScreenContainer.tsx | 41 + .../DefiPositionDetailsTitle.tsx | 25 + .../DefiPositionDetailsTokensInfo.tsx | 172 + .../useDefiPositionBreakdownInfo.tsx | 121 + .../features/defi/staking/InvestmentInfo.tsx | 101 + .../LiquidStakingProviderSelectScreen.tsx | 141 + ...idStakingProviderSelectScreenContainer.tsx | 10 + .../NativeStakingProviderSelectScreen.tsx | 179 + ...veStakingProviderSelectScreenContainer.tsx | 37 + .../defi/staking/NativeStakingScreen.tsx | 91 + .../staking/NativeStakingScreenContainer.tsx | 356 + .../ui/features/defi/staking/StakerIcon.tsx | 34 + .../defi/staking/StakingButtonCell.tsx | 62 + .../defi/staking/StakingScreenContainer.tsx | 46 + .../defi/staking/StakingWarningBox.tsx | 32 + .../features/defi/staking/UnstakingScreen.tsx | 175 + .../defi/staking/UnstakingScreenContainer.tsx | 62 + .../defi/staking/WithdrawWarningModal.tsx | 141 + .../defi/staking/hooks/useMaxFeeForStaking.ts | 124 + .../useStrkDelegatedStakingInvestments.ts | 74 + .../extension/src/ui/features/dev/DevUI.tsx | 147 +- .../src/ui/features/dev/useDevStorageUI.tsx | 83 - .../discover/AccountDiscoverScreen.tsx | 14 +- .../AccountDiscoverScreenContainer.tsx | 3 +- .../ui/features/discover/ui/NewsItemCard.tsx | 17 +- .../discover/ui/NewsItemCardCollection.tsx | 4 +- .../features/funding/FundingBridgeScreen.tsx | 106 +- .../funding/FundingFaucetFallbackScreen.tsx | 38 +- .../funding/FundingFaucetSepoliaScreen.tsx | 68 +- .../features/funding/FundingOnRampOption.tsx | 65 +- .../funding/FundingProviderScreen.tsx | 151 +- .../features/funding/FundingQrCodeScreen.tsx | 19 +- .../funding/FundingQrCodeScreenContainer.tsx | 2 +- .../src/ui/features/funding/FundingScreen.tsx | 138 +- .../src/ui/features/funding/constants.ts | 19 + .../ImportErrorBottomModal.tsx | 69 + .../ImportPrivateKeyScreen.tsx | 216 + .../CreateMultisigWithLedgerScreen.tsx | 24 +- .../ImportLedgerAccounts.tsx | 14 +- .../ImportLedgerAccountsContainer.tsx | 15 +- .../ImportLedgerAccountsError.tsx | 8 +- .../ImportLedgerAccountsLoading.tsx | 4 +- .../ImportLedgerAccountsSuccess.tsx | 41 +- .../ImportLedgerAccounts/Pagination.tsx | 17 +- .../JoinMultisigSidePanel.tsx | 6 +- .../JoinMultisigWithLedger.tsx | 19 +- .../LedgerConnect/ConnectInstructionBox.tsx | 21 +- .../LedgerConnect/LedgerConnectSidePanel.tsx | 10 +- .../LedgerConnect/LedgerConnectStep.tsx | 10 +- .../LedgerConnect/LedgerConnectionError.tsx | 11 +- .../LedgerConnect/LedgerIllustration.tsx | 2 +- .../ledger/LedgerReconnectSuccess.tsx | 40 + .../ui/features/ledger/LedgerStartScreen.tsx | 75 +- .../ReplaceMultisigOwnerWithLedger.tsx | 127 + .../RestoreDetecting.tsx | 2 +- .../RestoreFound.tsx | 12 +- .../RestoreMultisigSidePanel.tsx | 8 +- .../RestoreMultisigWithLedger.tsx | 11 +- .../RestoreNotFound.tsx | 9 +- .../ledger/hooks/useGetLedgerAccounts.ts | 3 - .../ledger/hooks/useIsLedgerSigner.ts | 6 +- .../features/ledger/hooks/useLedgerConnect.ts | 9 +- .../ledger/hooks/useLedgerDeviceConnection.ts | 15 +- .../features/ledger/hooks/useLedgerStatus.ts | 6 +- .../features/ledger/hooks/useOnLedgerStart.ts | 7 +- .../src/ui/features/ledger/layout/Panel.tsx | 22 +- .../features/ledger/layout/ScreenLayout.tsx | 15 +- .../src/ui/features/ledger/utils/index.ts | 6 +- .../features/legal/LegalAgreementsBanner.tsx | 11 +- .../legal/LegalAgreementsBannerContainer.tsx | 2 +- .../src/ui/features/lock/Greetings.tsx | 7 +- .../src/ui/features/lock/LockScreen.tsx | 22 +- .../src/ui/features/lock/PasswordForm.tsx | 115 +- .../src/ui/features/lock/ResetScreen.tsx | 32 +- .../src/ui/features/lock/WithLockScreen.tsx | 2 +- .../src/ui/features/multisig/AddOwnerForm.tsx | 17 +- .../CreateMultisigStartScreen.tsx | 2 +- .../MultisigCreationForm.tsx | 10 +- .../MultisigFirstStep.tsx | 12 +- .../MultisigSecondStep.tsx | 28 +- .../MultisigThirdStep.tsx | 21 +- .../CreateMultisigScreen/ScreenLayout.tsx | 24 +- .../features/multisig/JoinMultisigScreen.tsx | 37 +- .../multisig/JoinMultisigSettingsScreen.tsx | 19 +- .../src/ui/features/multisig/Multisig.ts | 13 +- .../multisig/MultisigAddOwnersScreen.tsx | 25 +- .../ui/features/multisig/MultisigBanner.tsx | 58 - .../multisig/MultisigConfirmationsScreen.tsx | 28 +- .../features/multisig/MultisigDeleteModal.tsx | 24 +- .../features/multisig/MultisigHideModal.tsx | 22 +- .../multisig/MultisigListAccounts.tsx | 9 +- .../MultisigListScreenItemContainer.tsx | 9 +- .../ui/features/multisig/MultisigOwner.tsx | 55 +- .../multisig/MultisigOwnerNameModal.tsx | 9 +- .../multisig/MultisigOwnersScreen.tsx | 33 +- ...gPendingOffchainSignatureDetailsScreen.tsx | 10 +- ...ultisigPendingTransactionDetailsScreen.tsx | 119 +- .../multisig/MultisigRemoveOwnerModal.tsx | 12 +- .../multisig/MultisigRemoveOwnerScreen.tsx | 11 +- .../multisig/MultisigReplaceOwnerScreen.tsx | 58 +- .../multisig/MultisigSettingsWrapper.tsx | 3 +- .../MultisigSignatureScreenWarning.tsx | 16 +- .../MultisigSignerSelectionScreen.tsx | 60 +- ...MultisigTransactionConfirmationsScreen.tsx | 21 +- ...ransactionConfirmationsScreenContainer.tsx | 20 +- .../features/multisig/NewMultisigScreen.tsx | 34 +- .../multisig/PendingMultisigListItem.tsx | 34 +- .../PendingMultisigListScreenItem.tsx | 25 +- .../features/multisig/RejectOnChainModal.tsx | 67 - .../RemoveMultisigSettingScreen.test.tsx | 2 +- .../RemovedMultisigSettingsScreen.tsx | 15 +- ...RemovedMultisigSettingsScreenContainer.tsx | 22 +- .../multisig/RemovedMultisigWarningScreen.tsx | 2 +- .../ui/features/multisig/ReplaceOwnerForm.tsx | 74 +- .../multisig/SetConfirmationsInput.tsx | 18 +- .../src/ui/features/multisig/constants.ts | 2 +- .../hooks/useCreateMultisigForm.test.tsx | 6 +- .../multisig/hooks/useCreateMultisigForm.ts | 28 - .../hooks/useCreatePendingMultisig.ts | 4 +- .../hooks/useHasDimissedUpgradeBanner.tsx | 2 +- .../multisig/hooks/useIsMultisigDeploying.ts | 2 +- .../useLedgerForPendingMultisigTransaction.ts | 4 +- .../hooks/useOnArgentSignerSelection.ts | 26 +- .../multisig/hooks/useReplaceMultisigOwner.ts | 44 + .../src/ui/features/multisig/multisig.mock.ts | 17 +- .../ui/features/multisig/multisig.state.ts | 85 +- .../multisigOffchainSignatures.state.ts | 6 +- .../multisig/multisigTransactions.state.ts | 7 +- .../AccountDetailsNavigationBar.tsx | 92 + .../AccountDetailsNavigationBarContainer.tsx | 29 + .../AccountDetailsNavigationContainer.tsx | 18 + .../navigation/AccountNavigationBar.tsx | 134 + .../AccountNavigationBarContainer.tsx | 70 + .../features/navigation/LedgerStatusText.tsx | 73 + .../NetworkSwitcher.test.tsx | 20 +- .../NetworkSwitcherButton.tsx | 18 +- .../navigation/NetworkSwitcherContainer.tsx | 60 + .../navigation/NetworkSwitcherList.tsx | 76 + .../navigation/SettingsBarIconButton.tsx | 26 + .../ui/features/networks/CongestionIcon.tsx | 2 +- .../NetworkSwitcherContainer.tsx | 67 - .../NetworkSwitcher/NetworkSwitcherList.tsx | 74 - .../NetworkWarningScreen.tsx | 16 +- .../NetworkWarningScreenContainer.tsx | 2 +- .../ui/features/networks/hooks/useNetworks.ts | 19 +- .../OnboardingAccountTypeScreen.tsx | 33 +- ...oardingAccountTypeScreenContainer.test.tsx | 57 +- .../OnboardingAccountTypeScreenContainer.tsx | 18 +- .../OnboardingFinishScreen.test.tsx | 30 +- .../onboarding/OnboardingFinishScreen.tsx | 78 +- .../OnboardingFinishScreenContainer.tsx | 14 +- .../onboarding/OnboardingPasswordScreen.tsx | 21 +- .../OnboardingPasswordScreenContainer.tsx | 10 +- .../onboarding/OnboardingPrivacyScreen.tsx | 46 +- .../OnboardingPrivacyScreenContainer.tsx | 13 +- .../OnboardingRestoreBackupScreen.tsx | 9 +- ...OnboardingRestoreBackupScreenContainer.tsx | 10 +- ...boardingRestorePasswordScreenContainer.tsx | 17 +- .../OnboardingRestoreSeedScreen.tsx | 13 +- .../OnboardingRestoreSeedScreenContainer.tsx | 5 +- .../OnboardingSmartAccountEmailScreen.tsx | 109 +- ...SmartAccountEmailScreenContainer.test.tsx} | 32 +- ...ardingSmartAccountEmailScreenContainer.tsx | 55 + .../OnboardingSmartAccountErrorScreen.tsx | 6 +- .../OnboardingSmartAccountOTPScreen.tsx | 85 +- ...ngSmartAccountOTPScreenContainer.test.tsx} | 36 +- ...boardingSmartAccountOTPScreenContainer.tsx | 59 + .../onboarding/OnboardingStartScreen.tsx | 18 +- .../OnboardingStartScreenContainer.tsx | 8 +- .../features/onboarding/ui/ArgentLinksRow.tsx | 106 - .../ui/features/onboarding/ui/KeyAsset.tsx | 133 - .../ui/features/onboarding/ui/LockAsset.tsx | 143 - .../ui/features/onboarding/ui/MobileAsset.tsx | 75 - .../onboarding/ui/OnboardingButton.tsx | 4 +- .../onboarding/ui/OnboardingCheckbox.tsx | 20 +- .../onboarding/ui/OnboardingContainer.tsx | 56 + .../ui/OnboardingFinishArgentLinksRow.tsx | 96 + ...nboardingFinishSmartAccountFeaturesRow.tsx | 73 + .../onboarding/ui/OnboardingRectButton.tsx | 3 +- .../onboarding/ui/OnboardingScreen.tsx | 79 +- .../ui/OnboardingSmartAccountFeaturesRow.tsx | 96 - .../onboarding/ui/OnboardingToastMessage.tsx | 10 +- .../onboarding/ui/OnchainRecoveryAsset.tsx | 102 - .../ui/features/recovery/CopySeedPhrase.tsx | 14 +- .../features/recovery/RecoverySetupScreen.tsx | 57 +- .../src/ui/features/recovery/SeedPhrase.tsx | 2 +- .../recovery/SeedPhraseWithCopyButton.tsx | 6 +- .../recovery/SeedRecoverySetupScreen.tsx | 22 +- .../recovery/hooks/useCustomNavigate.ts | 3 +- .../features/recovery/hooks/useSeedPhrase.ts | 2 +- .../recovery/ui/CircleIconContainer.tsx | 2 +- .../features/recovery/ui/ComingSoonIcon.tsx | 2 +- .../recovery/ui/LoadingSeedWordBadge.tsx | 5 +- .../features/recovery/ui/SeedPhraseGrid.tsx | 10 +- .../ui/features/recovery/ui/SeedWordBadge.tsx | 7 +- .../recovery/ui/SeedWordBadgeNumber.tsx | 5 +- .../ui/features/recovery/ui/WarningText.tsx | 2 +- .../src/ui/features/root/RootTabs.tsx | 36 +- .../root/RootTabsEmptyScreenContainer.tsx | 13 + .../src/ui/features/root/RootTabsScreen.tsx | 8 +- .../features/root/RootTabsScreenContainer.tsx | 49 +- .../features/send/AccountListWithBalance.tsx | 8 +- .../src/ui/features/send/NftInput.tsx | 26 +- .../SendAmountAndAssetNftScreenContainer.tsx | 11 +- .../send/SendAmountAndAssetScreen.tsx | 10 +- .../SendAmountAndAssetScreenContainer.tsx | 3 +- ...SendAmountAndAssetTokenScreenContainer.tsx | 17 +- .../src/ui/features/send/SendAssetScreen.tsx | 16 +- .../SendCollectionNftsScreenContainer.tsx | 2 +- .../send/SendModalAddContactScreen.tsx | 11 +- .../ui/features/send/SendRecipientScreen.tsx | 121 +- .../send/SendRecipientScreenContainer.tsx | 51 +- .../src/ui/features/send/TokenAmountInput.tsx | 35 +- .../send/useGetAddressFromDomainName.ts | 5 +- .../src/ui/features/send/useSendQuery.ts | 7 +- .../ui/features/settings/SettingsScreen.tsx | 126 +- .../settings/SettingsScreenContainer.tsx | 18 +- .../AccountEditButtons/AccountEditButtons.tsx | 4 +- .../AccountEditButtonsContainer.tsx | 11 +- .../AccountEditButtonsImported.tsx | 21 + .../AccountEditButtonsLedger.tsx | 28 +- .../AccountEditButtonsMultisig.tsx | 2 +- .../buttons/ConnectedDappsButton.tsx | 6 +- .../buttons/DeployAccountButton.tsx | 7 +- .../buttons/HideOrDeleteAccountButton.tsx | 52 +- .../buttons/MultisigOwnersButton.tsx | 6 +- .../buttons/MultisigThresholdButton.tsx | 22 +- .../buttons/PrivateKeyExportButton.tsx | 18 +- .../buttons/PublicKeyExportButton.tsx | 6 +- .../buttons/SmartAccountToggleButton.tsx | 33 +- .../buttons/ViewOnExplorerButton.tsx | 8 +- .../settings/account/AccountEditName.tsx | 11 +- .../account/AccountSettingsScreen.tsx | 34 +- .../ChangeAccountImplementationScreen.tsx | 40 +- .../account/ExportPrivateKeyScreen.tsx | 40 +- .../ExportPrivateKeyScreenContainer.tsx | 6 +- .../account/ExportPublicKeyScreen.tsx | 31 +- .../settings/account/Implementation.tsx | 20 +- .../AddressBookAddOrEditScreen.tsx | 46 +- .../AddressBookAddOrEditScreenContainer.tsx | 12 +- .../addressBook/AddressBookSettingsScreen.tsx | 24 +- .../AddressBookSettingsScreenContainer.tsx | 5 +- .../AdvancedSettingsScreen.tsx} | 40 +- .../AdvancedSettingsScreenContainer.tsx | 26 + .../BetaFeaturesSettingsScreen.tsx | 3 +- .../BetaFeaturesSettingsScreenContainer.tsx | 2 +- .../ClearLocalStorageScreen.tsx | 18 +- .../useClearLocalStorage.tsx | 0 .../deploymentData/DeploymentDataScreen.tsx | 19 +- .../downloadLogs/DownloadLogsScreen.test.tsx | 3 +- .../downloadLogs/DownloadLogsScreen.tsx | 29 +- .../downloadLogs/utils.ts | 0 .../ExperimentalSettingsScreen.tsx | 2 +- .../ExperimentalSettingsScreenContainer.tsx | 2 +- .../NetworkSettingsEditScreen.tsx | 2 +- .../NetworkSettingsFormScreen.tsx | 197 + .../NetworkSettingsFormScreenContainer.tsx | 9 +- .../manageNetworks/NetworkSettingsScreen.tsx | 14 +- .../NetworkSettingsScreenContainer.tsx | 28 +- .../manageNetworks/slugify.ts | 0 .../manageNetworks/validateRemoveNetwork.ts | 0 .../DappConnectionsAccountListScreen.tsx | 25 +- ...pConnectionsAccountListScreenContainer.tsx | 2 +- .../DappConnectionsAccountScreen.tsx | 22 +- .../DappConnectionsAccountScreenContainer.tsx | 4 +- .../DeveloperSettingsScreenContainer.tsx | 19 - .../NetworkSettingsFormScreen.tsx | 187 - .../ClassHashInputActions.tsx | 129 - .../ClassHashOption.tsx | 58 - .../DeclareOrDeployContractSuccessScreen.tsx | 95 - ...OrDeployContractSuccessScreenContainer.tsx | 20 - .../DeclareSmartContractForm.tsx | 341 - .../DeclareSmartContractScreen.tsx | 32 - .../DeploySmartContractForm.tsx | 202 - .../DeploySmartContractParameters.tsx | 122 - ...DeploySmartContractParametersContainer.tsx | 46 - .../DeploySmartContractScreen.tsx | 37 - .../SelectOptionAccount.tsx | 25 - .../SmartContractDevelopmentScreen.tsx | 48 - .../smartContractDevelopment/udc.state.ts | 24 - .../ui/ContractWithClassHash.tsx | 49 - .../ui/FileInputButton.tsx | 67 - .../useConstructorParams.tsx | 21 - .../useFormSelects.tsx | 60 - .../BlockExplorerSettingsScreen.tsx | 8 +- .../BlockExplorerSettingsScreenContainer.tsx | 4 +- .../preferences/IdProviderSettingsScreen.tsx | 54 + .../IdProviderSettingsScreenContainer.tsx | 22 + .../NftMarketplaceSettingsScreen.tsx | 8 +- .../NftMarketplaceSettingsScreenContainer.tsx | 4 +- .../preferences/PreferencesSettings.tsx | 26 +- .../PreferencesSettingsContainer.tsx | 11 +- .../PrivacySettingsScreen.tsx} | 41 +- .../PrivacySettingsScreenContainer.tsx} | 22 +- .../AutoLockTimerSettingsScreen.tsx | 2 +- .../AutoLockTimerSettingsScreenContainer.tsx | 2 +- .../BeforeYouContinueScreen.tsx | 0 .../SecurityAndRecoverySettingsScreen.tsx | 43 + ...rityAndRecoverySettingsScreenContainer.tsx | 32 + .../SeedSettingsScreen.tsx | 4 +- .../SeedSettingsScreenContainer.tsx | 3 +- .../settings/ui/DappConnectionMenuItem.tsx | 10 +- .../features/settings/ui/DapplandFooter.tsx | 47 +- .../ui/features/settings/ui/DapplandIcon.tsx | 2 +- .../settings/ui/PasswordWarningForm.tsx | 20 +- .../features/settings/ui/SettingsMenuItem.tsx | 18 +- .../settings/ui/SettingsMenuItemLogo.tsx | 5 +- .../settings/ui/SettingsNetworkListItem.tsx | 12 +- .../settings/ui/SettingsRadioIcon.tsx | 12 +- .../ui/features/settings/ui/SupportFooter.tsx | 78 +- .../settings/ui/WarningRecoveryBanner.tsx | 17 +- .../CreateSmartAccountEmailScreen.tsx | 3 +- .../CreateSmartAccountOTPScreen.tsx | 5 +- .../smartAccount/SmartAccountActionScreen.tsx | 9 +- .../smartAccount/SmartAccountActivate.tsx | 14 +- .../SmartAccountBaseActionScreen.tsx | 8 +- .../SmartAccountBaseFinishScreen.tsx | 66 +- .../SmartAccountBaseOTPScreen.tsx | 11 +- .../smartAccount/SmartAccountEmailScreen.tsx | 15 +- .../smartAccount/SmartAccountFinishScreen.tsx | 10 +- .../smartAccount/SmartAccountNotReady.tsx | 8 +- .../smartAccount/SmartAccountOTPForm.tsx | 95 +- .../smartAccount/SmartAccountOTPScreen.tsx | 29 +- .../smartAccount/SmartAccountStartScreen.tsx | 8 +- .../SmartAccountValidationErrorScreen.tsx | 11 +- .../smartAccount/WithSmartAccountVerified.tsx | 36 +- .../smartAccount/escape/EscapeBanner.tsx | 125 - .../smartAccount/escape/EscapeGuardian.tsx | 16 +- .../escape/EscapeGuardianReady.tsx | 12 +- .../smartAccount/escape/EscapeSigner.tsx | 20 +- .../escape/EscapeWarningScreen.tsx | 39 +- .../escape/UseAccountEscapeWarning.tsx | 2 +- .../smartAccount/escape/accountHasEscape.ts | 2 +- .../smartAccount/escape/escapeWarningStore.ts | 8 +- .../escape/getEscapeDisplayAttributes.tsx | 26 + .../escape/useAccountEscape.test.ts | 6 +- .../smartAccount/escape/useAccountEscape.ts | 40 +- .../smartAccount/ui/SmartAccountError.tsx | 8 +- .../ui/SmartAccountExternalLinkButton.tsx | 8 +- .../smartAccount/ui/SmartAccountIconRow.tsx | 9 +- .../ui/SmartAccountLearnMoreButton.tsx | 2 +- .../smartAccount/useAccountGuardian.ts | 9 +- .../usePendingChangingGuardian.ts | 6 +- .../smartAccount/useRouteWalletAccount.ts | 13 +- .../useToggleSmartAccountRoute.ts | 10 +- .../CaptureEntryRouteRestorationState.tsx | 2 +- .../stateRestoration/useRestorationState.ts | 25 +- .../statusMessage/StatusMessageBanner.tsx | 169 - .../StatusMessageBannerContainer.tsx | 20 - .../statusMessage/StatusMessageFullScreen.tsx | 127 +- .../StatusMessageFullScreenContainer.tsx | 22 + .../statusMessage/StatusMessageIcon.tsx | 32 +- .../statusMessage/getColorForLevel.tsx | 21 - .../statusMessage/statusMessageVisibility.ts | 2 +- .../statusMessage/useStatusMessage.ts | 2 +- .../extension/src/ui/features/swap/NoSwap.tsx | 10 +- .../src/ui/features/swap/Swap.test.tsx | 52 +- .../extension/src/ui/features/swap/Swap.tsx | 36 +- .../ui/features/swap/SwapScreenContainer.tsx | 27 +- .../swap/hooks/useSwapActionHandler.ts | 5 +- .../ui/features/swap/hooks/useSwapCallback.ts | 2 +- .../src/ui/features/swap/hooks/useSwapInfo.ts | 26 +- .../features/swap/hooks/useSwapQuoteForPay.ts | 19 +- .../swap/hooks/useTradeFromSwapQuote.ts | 4 +- .../src/ui/features/swap/state/fields.ts | 150 +- .../features/swap/ui/HighPriceImpactModal.tsx | 12 +- .../src/ui/features/swap/ui/OwnedToken.tsx | 8 +- .../src/ui/features/swap/ui/SlippageModal.tsx | 12 +- .../ui/features/swap/ui/SwapInputPanel.tsx | 36 +- .../ui/features/swap/ui/SwapPricesInfo.tsx | 46 +- .../ui/features/swap/ui/SwapQuoteRefresh.tsx | 12 +- .../ui/features/swap/ui/SwapTokensModal.tsx | 11 +- .../ui/features/swap/ui/SwapTradeLoading.tsx | 4 +- .../src/ui/features/swap/ui/TokenPrice.tsx | 10 +- .../src/ui/features/swap/ui/TokenValue.tsx | 10 +- .../src/ui/features/swap/utils/index.test.ts | 2 +- .../src/ui/features/swap/utils/index.ts | 15 +- .../src/ui/features/swap/utils/prices.test.ts | 3 +- .../src/ui/features/swap/utils/prices.ts | 6 +- .../ui/features/tokenDetails/OptionsMenu.tsx | 74 + .../TokenDetailsChartContainer.tsx | 78 + .../tokenDetails/TokenDetailsScreen.tsx | 530 + .../src/ui/features/tokenDetails/config.ts | 79 + .../tokenDetails/hooks/useTokenActivities.tsx | 33 + .../tokenDetails/hooks/useTokenGraphInfo.tsx | 49 + .../userReview/ReviewFeedbackScreen.tsx | 101 +- .../ReviewFeedbackScreenContainer.tsx | 5 +- .../userReview/ReviewRatingScreen.tsx | 74 +- .../src/ui/features/userReview/StarIcon.tsx | 18 + .../src/ui/features/userReview/StarRating.tsx | 91 + .../src/ui/hooks/useAutoFocusInputRef.ts | 8 +- .../src/ui/hooks/useCapsLockStatus.ts | 33 + .../src/ui/hooks/useNavigateReturnTo.ts | 3 +- .../hooks/useOnAppRoutesAnimationComplete.ts | 2 +- .../src/ui/hooks/useOnMountUnsafe.ts | 2 +- .../src/ui/hooks/useParseQuery.test.ts | 50 + .../extension/src/ui/hooks/useParseQuery.ts | 21 + .../extension/src/ui/hooks/useResetAll.ts | 4 +- packages/extension/src/ui/hooks/useRoute.ts | 31 +- .../src/ui/hooks/useStorage.test.tsx | 6 +- packages/extension/src/ui/hooks/useStorage.ts | 35 +- .../src/ui/hooks/useTabIndexWithHash.ts | 53 + .../ui/hooks/useTokenAmountToCcyCallback.ts | 30 + packages/extension/src/ui/index.html | 153 +- packages/extension/src/ui/index.tsx | 9 +- .../src/ui/providers/ArgentUIProviders.tsx | 42 +- packages/extension/src/ui/router/index.tsx | 41 + .../account/ClientAccountService.test.ts | 136 + .../services/account/ClientAccountService.ts | 108 +- .../services/account/IClientAccountService.ts | 17 +- .../src/ui/services/account/index.ts | 2 + .../AccountMessagingService.ts | 30 +- .../IAccountMessagingService.ts | 9 +- .../ui/services/action/ClientActionService.ts | 2 +- .../cache/ClientActivityCacheService.ts | 14 +- .../address/ClientStarknetAddressService.ts | 6 +- .../address/IClientStarknetAddressService.ts | 6 +- .../addressBook/ClientAddressBookService.ts | 2 +- .../ClientArgentAccountService.ts | 6 +- .../IClientArgentAccountService.ts | 1 + .../extension/src/ui/services/background.ts | 3 +- .../src/ui/services/backgroundAccounts.ts | 2 +- .../src/ui/services/backgroundTransactions.ts | 66 +- .../src/ui/services/blockExplorer.service.ts | 2 +- packages/extension/src/ui/services/crypto.ts | 2 +- .../discover/ClientDiscoverService.ts | 4 +- .../ui/services/feeToken/FeeTokenService.ts | 4 +- .../ClientImportAccountService.ts | 27 + .../importAccount/IClientImportAccount.ts | 5 + .../src/ui/services/importAccount/index.ts | 6 + .../services/investments/InvestmentService.ts | 14 + .../src/ui/services/investments/index.ts | 4 + .../src/ui/services/knownDapps/index.ts | 4 +- .../ui/services/knownDapps/knownDapps.test.ts | 2 +- .../src/ui/services/knownDapps/types.ts | 7 + .../knownDapps/useDappDisplayAttributes.ts | 23 + .../useDappFromKnownDappsByContractAddress.ts | 21 + .../knownDapps/useDappFromKnownDappsByHost.ts | 17 + .../knownDapps/useDappFromKnownDappsByName.ts | 18 + .../ui/services/knownDapps/useKnownDapps.ts | 7 + .../src/ui/services/ledger/ILedgerService.ts | 4 +- .../src/ui/services/ledger/LedgerService.ts | 6 +- .../multisig/ClientMultisigService.ts | 17 +- .../multisig/IClientMultisigService.ts | 4 +- .../src/ui/services/nfts/ClientNftService.ts | 10 +- .../src/ui/services/nfts/IClientNftService.ts | 4 +- .../src/ui/services/onRamp/OnRampService.ts | 6 +- .../onboarding/ClientOnboardingService.ts | 4 +- .../onboarding/useOnboardingExperiment.ts | 127 - .../PreAuthorizationUIService.ts | 8 +- .../recovery/ClientRecoveryService.ts | 2 +- .../src/ui/services/resetAndReload.tsx | 12 +- .../ClientRiskAssessmentService.ts | 4 +- .../src/ui/services/router/IRouterService.ts | 2 +- .../src/ui/services/router/RouterService.ts | 21 +- .../ui/services/session/ISessionService.ts | 2 +- .../src/ui/services/session/SessionService.ts | 4 +- .../ClientSignatureReviewService.ts | 4 +- .../src/ui/services/staking/StakingService.ts | 28 + .../src/ui/services/staking/index.ts | 4 + .../src/ui/services/swap/ISwapService.ts | 13 +- .../src/ui/services/swap/SwapService.ts | 20 +- .../extension/src/ui/services/swr.service.ts | 11 +- .../tokenDetails/ClientTokenDetailsService.ts | 38 + .../src/ui/services/tokenDetails/index.ts | 6 + ...dex.test.ts => ClientTokenService.test.ts} | 8 +- .../ui/services/tokens/ClientTokenService.ts | 60 +- .../ui/services/tokens/IClientTokenService.ts | 22 +- .../extension/src/ui/services/tokens/utils.ts | 2 +- .../ui/services/transactionReview/client.ts | 42 +- .../extension/src/ui/services/transactions.ts | 3 +- .../src/ui/services/udc/UdcService.ts | 6 +- .../src/ui/services/ui/ClientUIService.ts | 4 + .../src/ui/services/ui/IClientUIService.ts | 5 + .../src/ui/services/useOnClickOutside.ts | 3 +- .../src/ui/services/useStarknetId.ts | 163 +- packages/extension/src/ui/test/utils.tsx | 34 +- .../extension/src/ui/theme/Typography.tsx | 111 - packages/extension/src/ui/theme/index.tsx | 281 - packages/extension/src/ui/theme/styled.d.ts | 26 - packages/extension/src/ui/views/account.ts | 28 +- .../extension/src/ui/views/activityCache.ts | 7 +- .../extension/src/ui/views/estimatedFees.ts | 2 +- .../__tests__/AtomFromKeyValueStore.test.tsx | 7 +- .../__tests__/atomFromRepo.test.tsx | 7 +- .../__tests__/atomFromStore.test.tsx | 7 +- .../__tests__/atomWithSubscription.test.tsx | 7 +- .../__tests__/tokenPrices.test.ts | 1 + .../implementation/atomFromKeyValueStore.ts | 2 +- .../ui/views/implementation/atomFromRepo.ts | 4 +- .../extension/src/ui/views/investments.ts | 567 + packages/extension/src/ui/views/knownDapps.ts | 13 + packages/extension/src/ui/views/multisig.ts | 7 +- packages/extension/src/ui/views/network.ts | 25 +- packages/extension/src/ui/views/nft.ts | 14 +- .../src/ui/views/preAuthorizations.ts | 13 +- packages/extension/src/ui/views/staking.ts | 106 + packages/extension/src/ui/views/token.ts | 2 +- .../extension/src/ui/views/tokenBalances.ts | 41 +- .../extension/src/ui/views/tokenPrices.ts | 40 +- .../src/ui/views/transactionHashes.ts | 21 + .../src/ui/views/transactionReviews.ts | 5 +- .../extension/src/ui/views/transactions.ts | 2 +- packages/extension/test/account.mock.ts | 23 +- .../extension/test/accountIdSchema.test.ts | 81 + .../extension/test/accountsWithoutId.mock.ts | 81 + packages/extension/test/error.test.ts | 2 +- packages/extension/test/fetcher.test.ts | 9 +- packages/extension/test/knownDapps.test.ts | 53 - packages/extension/test/network.mock.ts | 2 +- packages/extension/test/statusMessage.test.ts | 2 +- packages/extension/test/token.mock.ts | 11 +- packages/extension/test/trade.mock.ts | 3 +- .../test/transactionSimulation.test.ts | 100 +- packages/extension/test/url.test.ts | 56 - packages/extension/test/wallet.test.ts | 79 +- packages/extension/test/walletAccount.mock.ts | 20 +- packages/extension/tsconfig.json | 5 +- packages/extension/vitest.config.ts | 12 +- packages/extension/webpack.config.ts | 58 +- packages/stack-router/example/src/App.tsx | 2 +- .../example/src/screens/Account.tsx | 6 +- .../stack-router/example/src/screens/Home.tsx | 6 +- packages/stack-router/package.json | 18 +- packages/stack-router/src/StackContext.tsx | 15 +- packages/stack-router/src/StackRoute.tsx | 7 +- packages/stack-router/src/StackRoutes.tsx | 3 +- .../stack-router/src/StackRoutesConfig.tsx | 5 +- packages/stack-router/src/StackScreen.tsx | 16 +- .../stack-router/src/StackScreenContainer.tsx | 11 +- .../src/presentation/getPresentationByPath.ts | 29 +- .../getWrappedChildrenAndPresentation.tsx | 11 +- .../src/presentation/presentationVariants.ts | 4 +- .../src/presentation/screenStack.test.ts | 2 +- .../src/presentation/screenStack.ts | 5 +- .../src/presentation/transitions.ts | 2 +- packages/stack-router/src/types.ts | 2 +- packages/stack-router/src/utils/is.ts | 2 +- packages/storybook/.storybook/main.ts | 20 +- packages/storybook/.storybook/preview.ts | 19 +- packages/storybook/package.json | 8 +- .../src/decorators/db/argentDbDecorator.tsx | 25 + .../src/decorators/db/tokenPrices.ts | 84 + .../storybook/src/decorators/db/tokens.ts | 109 + .../storybook/src/decorators/db/tokensInfo.ts | 168 + .../src/decorators/depreactedMuiDecorator.tsx | 13 - .../AccountCollections.stories.tsx | 38 + .../__fixtures__/account-collections.json | 163 + .../AccountTokensButtons.stories.tsx | 52 + .../AccountTokensButtonsSkeleton.stories.tsx | 12 + .../accountTokens/TokenListItem.stories.tsx | 1 + .../accounts/AccountActivity.stories.tsx | 28 - ...en.stories.tsx => AccountList.stories.tsx} | 4 +- .../AccountListHiddenScreen.stories.tsx | 9 +- .../accounts/AccountListItem.stories.tsx | 66 +- .../accounts/AccountNavigationBar.stories.tsx | 49 - .../accounts/AccountSelect.stories.tsx | 4 +- .../accounts/AddNewAccountScreen.stories.tsx | 36 +- .../ImportPrivateKeyScreen.stories.tsx | 12 + .../accounts/PendingTransactions.stories.tsx | 22 - ...TransactionCallDataBottomSheet.stories.tsx | 13 - .../TransactionDetailExplorer.stories.tsx | 217 - .../accounts/TransactionDetailRaw.stories.tsx | 171 - .../accounts/TransactionDetailWrapped.tsx | 53 - .../accounts/TransactionListItem.stories.tsx | 286 - .../__fixtures__/transactions-pending.json | 30 - .../accounts/__fixtures__/transactions.json | 287 - .../ApproveTransactionScreen.stories.tsx | 4 +- .../actions/ConfirmScreen.stories.tsx | 10 +- .../transaction/KnownDappButton.stories.tsx | 9 - .../transaction/TokenField.stories.tsx | 37 - .../TransactionActions.stories.tsx | 26 - .../NavigationBarAccountDetails.stories.tsx | 41 - .../TokenPickerScreen.stories.tsx | 28 - .../banners/AccountBanners.stories.tsx | 122 + .../AccountDeprecatedBanner.stories.tsx | 19 + .../banners/AccountOwnerBanner.stories.tsx | 19 + .../src/features/banners/Banner.stories.tsx | 81 + .../features/banners/EscapeBanner.stories.tsx | 98 + .../banners/MultisigBanner.stories.tsx | 27 + .../banners/PromoStakingBanner.stories.tsx | 15 + .../SaveRecoverySeedphraseBanner.stories.tsx | 15 + .../banners/StatusMessageBanner.stories.tsx | 45 + .../StatusMessageFullScreen.stories.tsx | 45 + .../banners/UpgradeBanner.stories.tsx | 26 + .../features/banners/__fixtures__/danger.json | 11 + .../features/banners/__fixtures__/info.json | 13 + .../features/banners/__fixtures__/null.json | 5 + .../banners/__fixtures__/statusMesages.ts | 15 + .../banners/__fixtures__/upgrade.json | 13 + .../banners/__fixtures__/warning.json | 9 + .../defiDecomposition/DefiIcon.stories.tsx | 161 + .../DefiPosition.stories.tsx | 144 + .../defiDecomposition/TokenInfo.stories.tsx | 123 + .../__fixtures__/parsedDefiDecomposition.ts | 499 + .../parsedDefiDecompositionWithUsdValue.ts | 503 + .../ImportPrivateKeyScreen.stories.tsx | 12 + .../ledger/LedgerConnectSidePanel.stories.tsx | 2 +- .../AccountDetailsNavigationBar.stories.tsx | 67 + .../AccountNavigationBar.stories.tsx | 82 + .../NetworkSwitcherList.stories.tsx | 71 + .../OnboardingAccountTypeScreen.stories.tsx | 19 + .../OnboardingFinishScreen.stories.tsx | 13 +- .../OnboardingPasswordScreen.stories.tsx | 9 +- .../OnboardingPrivacyScreen.stories.tsx | 14 + .../OnboardingRestoreBackup.stories.tsx | 9 +- .../OnboardingRestoreSeedScreen.stories.tsx | 9 +- .../onboarding/OnboardingScreen.stories.tsx | 13 +- ...oardingSmartAccountEmailScreen.stories.tsx | 17 + ...nboardingSmartAccountOTPScreen.stories.tsx | 17 + .../OnboardingStartScreen.stories.tsx | 9 +- .../OnboardingToastMessage.stories.tsx | 14 +- ...tsx => AdvancedSettingsScreen.stories.tsx} | 4 +- .../AutoLockTimerSettingsScreen.stories.tsx | 2 +- .../BetaFeaturesSettingsScreen.stories.tsx | 2 +- ...eOrDeployContractSuccessScreen.stories.tsx | 23 - .../DeclareSmartContractForm.stories.tsx | 12 - .../DeploySmartContractForm.stories.tsx | 12 - .../NetworkSettingsScreen.stories.tsx | 2 +- ...rityAndRecoverySettingsScreen.stories.tsx} | 4 +- .../settings/SeedSettingsScreen.stories.tsx | 2 +- .../settings/SettingsScreen.stories.tsx | 12 +- .../smartAccount/EscapeGuardian.stories.tsx | 2 +- .../smartAccount/EscapeSigner.stories.tsx | 2 +- .../staking/ProviderSelectScreen.stories.tsx | 21 + .../staking/UnstakingScreen.stories.tsx | 34 + .../__fixtures__/defi-investments.json | 386 + .../starknet-staking-investments.json | 92 + .../StatusMessageBanner.stories.tsx | 32 - .../StatusMessageFullScreen.stories.tsx | 32 - .../__fixtures__/status-messages.json | 53 - .../AppErrorBoundaryFallback.stories.tsx | 12 + .../ui/components/CopyIconButton.stories.tsx | 18 - .../components/DappContractField.stories.tsx | 33 - .../ui/components/ErrorBoundary.stories.tsx | 30 +- .../src/ui/components/InputText.stories.tsx | 66 - .../ui/components/InputTextArea.stories.tsx | 61 - .../src/ui/components/Option.stories.tsx | 53 +- .../ui/components/WarningScreen.stories.tsx | 2 +- packages/window/.eslintrc.js | 18 - packages/window/.gitignore | 24 - packages/window/CHANGELOG.md | 7 - packages/window/package.json | 49 - packages/window/src/account.ts | 63 - packages/window/src/eventHandlers.ts | 3 - packages/window/src/index.ts | 48 - .../src/messages/__tests__/relayer.test.ts | 138 - .../src/messages/__tests__/window.test.ts | 39 - .../src/messages/__tests__/windowMock.mock.ts | 53 - .../src/messages/exchange/bidirectional.ts | 140 - .../window/src/messages/exchange/relayer.ts | 47 - .../window/src/messages/messenger/index.ts | 32 - .../window/src/messages/messenger/window.ts | 76 - packages/window/src/starknet.ts | 213 - packages/window/src/types.test.ts | 917 - packages/window/src/types.ts | 273 - packages/window/src/utils/mittx.ts | 122 - packages/window/src/vite-env.d.ts | 1 - packages/window/tsconfig.json | 19 - packages/window/vite.config.ts | 24 - packages/window/vitest.config.ts | 13 - pnpm-lock.yaml | 14066 ++++++++++------ renovate.json | 13 +- .../calculate-contract-hash/requirements.txt | 2 +- scripts/devnet-upgrade-helper.ts | 1 - 1890 files changed, 55552 insertions(+), 34936 deletions(-) create mode 100644 .github/workflows/upgrade-tests.yml delete mode 100644 packages/e2e/.eslintrc.js delete mode 100644 packages/e2e/Dockerfile delete mode 100644 packages/e2e/extension/src/languages/ILanguage.ts delete mode 100644 packages/e2e/extension/src/specs/tokens.spec.ts delete mode 100644 packages/e2e/extension/src/test.ts rename packages/e2e/{extension => }/playwright.config.ts (53%) delete mode 100644 packages/e2e/shared/cfg/global.teardown.ts delete mode 100644 packages/e2e/shared/cfg/test.ts delete mode 100644 packages/e2e/shared/config.ts delete mode 100644 packages/e2e/shared/src/Utils.ts delete mode 100644 packages/e2e/shared/src/slack.spec.ts delete mode 100644 packages/e2e/shared/src/slack.ts rename packages/e2e/{extension => }/src/config.ts (52%) rename packages/e2e/{extension => }/src/fixtures.ts (89%) create mode 100644 packages/e2e/src/languages/ILanguage.ts rename packages/e2e/{extension => }/src/languages/en/index.ts (90%) rename packages/e2e/{extension => }/src/languages/index.ts (55%) rename packages/e2e/{extension => }/src/page-objects/Account.ts (82%) rename packages/e2e/{extension => }/src/page-objects/Activity.ts (100%) rename packages/e2e/{extension => }/src/page-objects/AddressBook.ts (83%) rename packages/e2e/{extension => }/src/page-objects/Dapps.ts (93%) rename packages/e2e/{extension => }/src/page-objects/DeveloperSettings.ts (75%) rename packages/e2e/{extension => }/src/page-objects/ExtensionPage.ts (76%) rename packages/e2e/{extension => }/src/page-objects/Messages.ts (100%) rename packages/e2e/{extension => }/src/page-objects/Navigation.ts (97%) rename packages/e2e/{extension => }/src/page-objects/Network.ts (66%) rename packages/e2e/{extension => }/src/page-objects/Nfts.ts (88%) rename packages/e2e/{extension => }/src/page-objects/Preferences.ts (96%) rename packages/e2e/{extension => }/src/page-objects/Settings.ts (65%) create mode 100644 packages/e2e/src/page-objects/Swap.ts create mode 100644 packages/e2e/src/page-objects/TokenDetails.ts rename packages/e2e/{extension => }/src/page-objects/Wallet.ts (72%) rename packages/e2e/{extension => }/src/specs/accountSettings.spec.ts (93%) rename packages/e2e/{extension => }/src/specs/addressBook.spec.ts (80%) rename packages/e2e/{extension => }/src/specs/dapps.spec.ts (95%) rename packages/e2e/{extension => }/src/specs/defaultAccount2smartAccount.spec.ts (85%) create mode 100644 packages/e2e/src/specs/importAccounts.spec.ts rename packages/e2e/{extension => }/src/specs/invalidAddress.spec.ts (91%) rename packages/e2e/{extension => }/src/specs/links.spec.ts (100%) rename packages/e2e/{extension => }/src/specs/multisig.spec.ts (94%) rename packages/e2e/{extension => }/src/specs/network.spec.ts (88%) rename packages/e2e/{extension => }/src/specs/nfts.spec.ts (92%) rename packages/e2e/{extension => }/src/specs/recovery.spec.ts (87%) rename packages/e2e/{extension => }/src/specs/sendMaxFunds.spec.ts (92%) rename packages/e2e/{extension => }/src/specs/sendPartialFunds.spec.ts (92%) rename packages/e2e/{extension => }/src/specs/smartAccount.spec.ts (54%) create mode 100644 packages/e2e/src/specs/swap.spec.ts create mode 100644 packages/e2e/src/specs/tokenDetails.spec.ts create mode 100644 packages/e2e/src/specs/tokens.spec.ts create mode 100644 packages/e2e/src/specs/upgrade.spec.ts rename packages/e2e/{extension => }/src/specs/welcome.spec.ts (92%) create mode 100644 packages/e2e/src/test.ts create mode 100644 packages/e2e/src/utils/Clipboard.ts rename packages/e2e/{shared/src => src/utils}/assets.ts (87%) rename packages/e2e/{shared/src => src/utils}/common.ts (88%) create mode 100644 packages/e2e/src/utils/download.ts create mode 100755 packages/e2e/src/utils/getBranchVersion.sh create mode 100644 packages/e2e/src/utils/getBranchVersion.ts create mode 100644 packages/e2e/src/utils/global.teardown.ts create mode 100644 packages/e2e/src/utils/index.ts create mode 100644 packages/e2e/src/utils/qaUtils.ts create mode 100644 packages/e2e/src/utils/slackNotif.js create mode 100755 packages/e2e/src/utils/unzip.sh create mode 100644 packages/e2e/src/utils/unzip.ts create mode 100755 packages/e2e/until-failure create mode 100644 packages/extension/build/htmlWebpackInlineStylePlugin.ts create mode 100644 packages/extension/scripts/check-bundle-size.ts create mode 100644 packages/extension/src/assets/barlow/LICENSE create mode 100644 packages/extension/src/assets/barlow/barlow-latin-300-normal.woff create mode 100644 packages/extension/src/assets/barlow/barlow-latin-300-normal.woff2 create mode 100644 packages/extension/src/assets/barlow/barlow-latin-400-normal.woff create mode 100644 packages/extension/src/assets/barlow/barlow-latin-400-normal.woff2 create mode 100644 packages/extension/src/assets/barlow/barlow-latin-500-normal.woff create mode 100644 packages/extension/src/assets/barlow/barlow-latin-500-normal.woff2 create mode 100644 packages/extension/src/assets/barlow/barlow-latin-600-normal.woff create mode 100644 packages/extension/src/assets/barlow/barlow-latin-600-normal.woff2 create mode 100644 packages/extension/src/assets/barlow/barlow-latin-700-normal.woff create mode 100644 packages/extension/src/assets/barlow/barlow-latin-700-normal.woff2 delete mode 100644 packages/extension/src/assets/known-dapps.json create mode 100644 packages/extension/src/assets/onboarding/2fa.svg create mode 100644 packages/extension/src/assets/onboarding/account-smart.svg create mode 100644 packages/extension/src/assets/onboarding/account-standard.svg create mode 100644 packages/extension/src/assets/onboarding/default.svg create mode 100644 packages/extension/src/assets/onboarding/email-wrong.svg create mode 100644 packages/extension/src/assets/onboarding/email.svg create mode 100644 packages/extension/src/assets/onboarding/finish/2fa-protection.svg create mode 100644 packages/extension/src/assets/onboarding/finish/download-mobile.svg create mode 100644 packages/extension/src/assets/onboarding/finish/explore-dapps.svg create mode 100644 packages/extension/src/assets/onboarding/finish/follow-us-on-x.svg create mode 100644 packages/extension/src/assets/onboarding/finish/on-chain-recovery.svg create mode 100644 packages/extension/src/assets/onboarding/finish/session-keys.svg create mode 100644 packages/extension/src/assets/onboarding/improve.svg create mode 100644 packages/extension/src/assets/onboarding/password-created.svg create mode 100644 packages/extension/src/assets/onboarding/password.svg create mode 100644 packages/extension/src/assets/staking/liquid-staking.png create mode 100644 packages/extension/src/assets/staking/native-staking.png create mode 100644 packages/extension/src/background/multisig/worker/MultisigWorker.test.ts delete mode 100644 packages/extension/src/background/nonce.ts create mode 100644 packages/extension/src/background/nonceManagement/INonceManagementService.ts create mode 100644 packages/extension/src/background/nonceManagement/NonceManagementService.test.ts create mode 100644 packages/extension/src/background/nonceManagement/NonceManagementService.ts create mode 100644 packages/extension/src/background/nonceManagement/index.ts create mode 100644 packages/extension/src/background/nonceManagement/store.ts create mode 100644 packages/extension/src/background/nonceManagement/worker/INonceManagementWorker.ts create mode 100644 packages/extension/src/background/nonceManagement/worker/NonceManagementWorker.test.ts create mode 100644 packages/extension/src/background/nonceManagement/worker/NonceManagementWorker.ts create mode 100644 packages/extension/src/background/nonceManagement/worker/index.ts create mode 100644 packages/extension/src/background/services/activity/schema.ts create mode 100644 packages/extension/src/background/services/dev/DevWorker.ts create mode 100644 packages/extension/src/background/services/dev/index.ts create mode 100644 packages/extension/src/background/services/investments/IBackgroundInvestmentService.ts create mode 100644 packages/extension/src/background/services/investments/InvestmentService.test.ts create mode 100644 packages/extension/src/background/services/investments/InvestmentService.ts create mode 100644 packages/extension/src/background/services/investments/index.ts create mode 100644 packages/extension/src/background/services/investments/worker/InvestmentWorker.ts create mode 100644 packages/extension/src/background/services/investments/worker/index.ts create mode 100644 packages/extension/src/background/services/staking/StakingService.ts create mode 100644 packages/extension/src/background/services/staking/index.ts create mode 100644 packages/extension/src/background/services/tokenDetails/BackgroundTokenDetailsService.ts create mode 100644 packages/extension/src/background/services/tokenDetails/index.ts create mode 100644 packages/extension/src/background/services/tokenDetails/tokenDetailsError.ts create mode 100644 packages/extension/src/background/services/transactionReview/types.ts create mode 100644 packages/extension/src/background/test/__fixtures__/activities.ts delete mode 100644 packages/extension/src/background/transactions/onupdate/nonce.ts create mode 100644 packages/extension/src/background/trpc/procedures/importAccount/import.ts create mode 100644 packages/extension/src/background/trpc/procedures/importAccount/index.ts create mode 100644 packages/extension/src/background/trpc/procedures/importAccount/validate.ts create mode 100644 packages/extension/src/background/trpc/procedures/investments/getAllInvestments.ts create mode 100644 packages/extension/src/background/trpc/procedures/investments/getStrkDelegatedStakingInvestments.ts create mode 100644 packages/extension/src/background/trpc/procedures/investments/index.ts create mode 100644 packages/extension/src/background/trpc/procedures/staking/claim.ts create mode 100644 packages/extension/src/background/trpc/procedures/staking/index.ts create mode 100644 packages/extension/src/background/trpc/procedures/staking/initiateUnstake.ts create mode 100644 packages/extension/src/background/trpc/procedures/staking/stake.ts create mode 100644 packages/extension/src/background/trpc/procedures/staking/stakeCalldata.ts create mode 100644 packages/extension/src/background/trpc/procedures/staking/unstake.ts create mode 100644 packages/extension/src/background/trpc/procedures/tokens/fetchTokenActivities.ts create mode 100644 packages/extension/src/background/trpc/procedures/tokens/fetchTokenGraph.ts create mode 100644 packages/extension/src/background/trpc/procedures/tokens/getTokenBalance.ts create mode 100644 packages/extension/src/background/trpc/procedures/tokens/hideTokenProcedure.ts create mode 100644 packages/extension/src/background/trpc/procedures/tokens/reportSpamToken.ts delete mode 100644 packages/extension/src/background/trpc/procedures/transactionReview/getTransactionHash.ts create mode 100644 packages/extension/src/background/trpc/procedures/ui/index.ts create mode 100644 packages/extension/src/background/trpc/procedures/ui/openUI.ts create mode 100644 packages/extension/src/inpage/provider.ts create mode 100644 packages/extension/src/navigator.d.ts create mode 100644 packages/extension/src/shared/account/utils.ts create mode 100644 packages/extension/src/shared/accountImport/account.ts create mode 100644 packages/extension/src/shared/accountImport/pkManager/IPKManager.ts create mode 100644 packages/extension/src/shared/accountImport/pkManager/PKManager.test.ts create mode 100644 packages/extension/src/shared/accountImport/pkManager/PKManager.ts create mode 100644 packages/extension/src/shared/accountImport/pkManager/index.ts create mode 100644 packages/extension/src/shared/accountImport/pkManager/storage.ts create mode 100644 packages/extension/src/shared/accountImport/service/AccountImportSharedService.test.ts create mode 100644 packages/extension/src/shared/accountImport/service/AccountImportSharedService.ts create mode 100644 packages/extension/src/shared/accountImport/service/IAccountImportSharedService.ts create mode 100644 packages/extension/src/shared/accountImport/service/index.ts create mode 100644 packages/extension/src/shared/accountImport/types.ts delete mode 100644 packages/extension/src/shared/activity/index.ts delete mode 100644 packages/extension/src/shared/activity/utils/transform/explorerTransaction/dappExplorerTransaction.ts delete mode 100644 packages/extension/src/shared/activity/utils/transform/explorerTransaction/dappTransaction.ts delete mode 100644 packages/extension/src/shared/activity/utils/transform/explorerTransaction/transformers/dappAlphaRoadSwapTransformer.ts delete mode 100644 packages/extension/src/shared/activity/utils/transform/explorerTransaction/transformers/dappAspectBuyNFTTransformer.ts delete mode 100644 packages/extension/src/shared/activity/utils/transform/explorerTransaction/transformers/dappInfluenceMintTransformer.ts delete mode 100644 packages/extension/src/shared/activity/utils/transform/explorerTransaction/transformers/dappJediswapSwapTransformer.ts delete mode 100644 packages/extension/src/shared/activity/utils/transform/explorerTransaction/transformers/dappMintSquareBuyNFTTransformer.ts delete mode 100644 packages/extension/src/shared/activity/utils/transform/explorerTransaction/transformers/dappMySwapSwapTransformer.ts delete mode 100644 packages/extension/src/shared/activity/utils/transform/explorerTransaction/transformers/knownDappTransformer.ts delete mode 100644 packages/extension/src/shared/activity/utils/transform/transaction/transformers/knownDappTransformer.ts create mode 100644 packages/extension/src/shared/call/changeMultisigSignersCall.test.ts create mode 100644 packages/extension/src/shared/defiDecomposition/__fixtures__/collateralizedDebtPositions.ts create mode 100644 packages/extension/src/shared/defiDecomposition/__fixtures__/concentratedLiquidityPositions.ts create mode 100644 packages/extension/src/shared/defiDecomposition/__fixtures__/defiDecomposition.ts create mode 100644 packages/extension/src/shared/defiDecomposition/__fixtures__/delegatedTokensPositions.ts create mode 100644 packages/extension/src/shared/defiDecomposition/__fixtures__/parsedDefiDecompositionWithUsdValue.ts create mode 100644 packages/extension/src/shared/defiDecomposition/__fixtures__/stakingPositions.ts create mode 100644 packages/extension/src/shared/defiDecomposition/__fixtures__/strkDelegatedStakingPositions.ts create mode 100644 packages/extension/src/shared/defiDecomposition/__fixtures__/tokenPrices.ts create mode 100644 packages/extension/src/shared/defiDecomposition/__fixtures__/tokens.ts create mode 100644 packages/extension/src/shared/defiDecomposition/__fixtures__/tokensInfo.ts create mode 100644 packages/extension/src/shared/defiDecomposition/getPositionTokenBalance.ts create mode 100644 packages/extension/src/shared/defiDecomposition/helpers/computeCollateralizedDebtPositionsUsdValue.test.ts create mode 100644 packages/extension/src/shared/defiDecomposition/helpers/computeCollateralizedDebtPositionsUsdValue.ts create mode 100644 packages/extension/src/shared/defiDecomposition/helpers/computeConcentratedLiquidityPositionsUsdValue.test.ts create mode 100644 packages/extension/src/shared/defiDecomposition/helpers/computeConcentratedLiquidityPositionsUsdValue.ts create mode 100644 packages/extension/src/shared/defiDecomposition/helpers/computeDefiDecompositionUsdValue.test.ts create mode 100644 packages/extension/src/shared/defiDecomposition/helpers/computeDefiDecompositionUsdValue.ts create mode 100644 packages/extension/src/shared/defiDecomposition/helpers/computeDelegatedTokensPositionsUsdValue.test.ts create mode 100644 packages/extension/src/shared/defiDecomposition/helpers/computeDelegatedTokensPositionsUsdValue.ts create mode 100644 packages/extension/src/shared/defiDecomposition/helpers/computeStakingPositionsUsdValue.test.ts create mode 100644 packages/extension/src/shared/defiDecomposition/helpers/computeStakingPositionsUsdValue.ts create mode 100644 packages/extension/src/shared/defiDecomposition/helpers/computeStrkDelegatedStakingPositionsUsdValue.test.ts create mode 100644 packages/extension/src/shared/defiDecomposition/helpers/computeStrkDelegatedStakingPositionsUsdValue.ts create mode 100644 packages/extension/src/shared/defiDecomposition/helpers/computeUsdValueForPosition.ts create mode 100644 packages/extension/src/shared/defiDecomposition/helpers/getDefiProductName.ts create mode 100644 packages/extension/src/shared/defiDecomposition/helpers/parseCollateralizedDebtPositions.test.ts create mode 100644 packages/extension/src/shared/defiDecomposition/helpers/parseCollateralizedDebtPositions.ts create mode 100644 packages/extension/src/shared/defiDecomposition/helpers/parseConcentratedLiquidityPositions.test.ts create mode 100644 packages/extension/src/shared/defiDecomposition/helpers/parseConcentratedLiquidityPositions.ts create mode 100644 packages/extension/src/shared/defiDecomposition/helpers/parseDefiDecomposition.test.ts create mode 100644 packages/extension/src/shared/defiDecomposition/helpers/parseDefiDecomposition.ts create mode 100644 packages/extension/src/shared/defiDecomposition/helpers/parseDelegatedTokensPositions.test.ts create mode 100644 packages/extension/src/shared/defiDecomposition/helpers/parseDelegatedTokensPositions.ts create mode 100644 packages/extension/src/shared/defiDecomposition/helpers/parseStakingPositions.test.ts create mode 100644 packages/extension/src/shared/defiDecomposition/helpers/parseStakingPositions.ts create mode 100644 packages/extension/src/shared/defiDecomposition/helpers/parseStrkDelegatedStakingPositions.test.ts create mode 100644 packages/extension/src/shared/defiDecomposition/helpers/parseStrkDelegatedStakingPositions.ts create mode 100644 packages/extension/src/shared/defiDecomposition/helpers/sortDescendingByUsdValue.ts create mode 100644 packages/extension/src/shared/defiDecomposition/index.ts create mode 100644 packages/extension/src/shared/defiDecomposition/schema.ts create mode 100644 packages/extension/src/shared/dev/store.ts create mode 100644 packages/extension/src/shared/dev/types.ts create mode 100644 packages/extension/src/shared/idb/argentDb.ts rename packages/extension/src/shared/idb/{ => middleware}/addressNormalizerMiddleware.ts (92%) create mode 100644 packages/extension/src/shared/idb/middleware/hideSpamTokensMiddleware.ts rename packages/extension/src/shared/idb/{ => utils}/chunkedBulkPut.ts (100%) create mode 100644 packages/extension/src/shared/idb/utils/deduplicateTable.ts create mode 100644 packages/extension/src/shared/investments/IInvestmentService.ts create mode 100644 packages/extension/src/shared/investments/types.ts create mode 100644 packages/extension/src/shared/messages/isLocalhost.ts delete mode 100644 packages/extension/src/shared/send/schema.test.ts create mode 100644 packages/extension/src/shared/signer/PrivateKeySigner.ts create mode 100644 packages/extension/src/shared/staking/IStakingService.ts create mode 100644 packages/extension/src/shared/staking/storage.ts create mode 100644 packages/extension/src/shared/staking/types.ts create mode 100644 packages/extension/src/shared/staking/utils.ts create mode 100644 packages/extension/src/shared/storage/__new/__test__/__fixtures__/storage.json create mode 100644 packages/extension/src/shared/storage/__new/replaceValueInStorage.test.ts create mode 100644 packages/extension/src/shared/storage/__new/replaceValueInStorage.ts create mode 100644 packages/extension/src/shared/storage/__new/utils.test.ts create mode 100644 packages/extension/src/shared/storage/__new/utils.ts create mode 100644 packages/extension/src/shared/test.utils.ts create mode 100644 packages/extension/src/shared/token/__new/utils/decodeShortStringArray.test.ts create mode 100644 packages/extension/src/shared/token/__new/utils/decodeShortStringArray.ts create mode 100644 packages/extension/src/shared/tokenDetails/interface.ts create mode 100644 packages/extension/src/shared/transactionReview/transactionAction.model.ts delete mode 100644 packages/extension/src/shared/transactionSimulation/transactionSimulation.service.ts create mode 100644 packages/extension/src/shared/transactions/transactionHashes/transactionHashesRepository.ts create mode 100644 packages/extension/src/shared/utils/accountIdentifier.test.ts create mode 100644 packages/extension/src/shared/utils/accountIdentifier.ts create mode 100644 packages/extension/src/shared/utils/accountsEqual.test.ts create mode 100644 packages/extension/src/shared/utils/getActiveFromNow.ts create mode 100644 packages/extension/src/shared/utils/isExternalAccount.ts create mode 100644 packages/extension/src/shared/utils/url.test.ts create mode 100644 packages/extension/src/shared/utils/validateSignatureChainId.test.ts create mode 100644 packages/extension/src/shared/utils/validateSignatureChainId.ts create mode 100644 packages/extension/src/ui/components/ActionButton.tsx delete mode 100644 packages/extension/src/ui/components/BackLink.tsx delete mode 100644 packages/extension/src/ui/components/BottomSheet.tsx delete mode 100644 packages/extension/src/ui/components/Button.tsx delete mode 100644 packages/extension/src/ui/components/Column.tsx delete mode 100644 packages/extension/src/ui/components/ControlledTextArea.tsx delete mode 100644 packages/extension/src/ui/components/CopyIconButton.tsx delete mode 100644 packages/extension/src/ui/components/CopyTooltip.tsx delete mode 100644 packages/extension/src/ui/components/DisclosureIcon.tsx delete mode 100644 packages/extension/src/ui/components/ErrorBoundaryFallback.tsx delete mode 100644 packages/extension/src/ui/components/Fields.tsx delete mode 100644 packages/extension/src/ui/components/Header.tsx delete mode 100644 packages/extension/src/ui/components/IOSSwitch.tsx delete mode 100644 packages/extension/src/ui/components/IconBar.tsx delete mode 100644 packages/extension/src/ui/components/IconButton.tsx delete mode 100644 packages/extension/src/ui/components/Icons/AlertIcon.tsx delete mode 100644 packages/extension/src/ui/components/Icons/ArgentXBanner.tsx delete mode 100644 packages/extension/src/ui/components/Icons/ArgentXIcon.tsx delete mode 100644 packages/extension/src/ui/components/Icons/ArgentXLogo.tsx delete mode 100644 packages/extension/src/ui/components/Icons/AspectLogo.tsx delete mode 100644 packages/extension/src/ui/components/Icons/BackIcon.tsx delete mode 100644 packages/extension/src/ui/components/Icons/CheckIcon.tsx delete mode 100644 packages/extension/src/ui/components/Icons/ChevronDown.tsx delete mode 100644 packages/extension/src/ui/components/Icons/ChevronRight.tsx delete mode 100644 packages/extension/src/ui/components/Icons/CloseIcon.tsx delete mode 100644 packages/extension/src/ui/components/Icons/CloseIconAlt.tsx delete mode 100644 packages/extension/src/ui/components/Icons/DangerIcon.tsx delete mode 100644 packages/extension/src/ui/components/Icons/DiscordIcon.tsx delete mode 100644 packages/extension/src/ui/components/Icons/EditIcon.tsx delete mode 100644 packages/extension/src/ui/components/Icons/HeartFilled.tsx delete mode 100644 packages/extension/src/ui/components/Icons/InfoCircle.tsx delete mode 100644 packages/extension/src/ui/components/Icons/MintSquareLogo.tsx delete mode 100644 packages/extension/src/ui/components/Icons/MuiIcons.ts delete mode 100644 packages/extension/src/ui/components/Icons/NetworkWarningIcon.tsx delete mode 100644 packages/extension/src/ui/components/Icons/PluginIcon.tsx delete mode 100644 packages/extension/src/ui/components/Icons/PlusCircle.tsx delete mode 100644 packages/extension/src/ui/components/Icons/RocketLaunchIcon.tsx delete mode 100644 packages/extension/src/ui/components/Icons/SearchIcon.tsx delete mode 100644 packages/extension/src/ui/components/Icons/SessionPluginIcon.tsx delete mode 100644 packages/extension/src/ui/components/Icons/StarknetIcon.tsx delete mode 100644 packages/extension/src/ui/components/Icons/SupportIcon.tsx delete mode 100644 packages/extension/src/ui/components/Icons/UpdateIcon.tsx delete mode 100644 packages/extension/src/ui/components/Icons/ViewOnBlockExplorerIcon.tsx delete mode 100644 packages/extension/src/ui/components/Icons/WarningIcon.tsx delete mode 100644 packages/extension/src/ui/components/Icons/WarningIconRounded.tsx delete mode 100644 packages/extension/src/ui/components/Icons/svg/argent-x-logo.svg delete mode 100644 packages/extension/src/ui/components/InputSelect.tsx delete mode 100644 packages/extension/src/ui/components/InputText.tsx delete mode 100644 packages/extension/src/ui/components/Menu.tsx create mode 100644 packages/extension/src/ui/components/Option.tsx delete mode 100644 packages/extension/src/ui/components/Options.tsx delete mode 100644 packages/extension/src/ui/components/Page.tsx delete mode 100644 packages/extension/src/ui/components/Row.tsx delete mode 100644 packages/extension/src/ui/components/ShortAddressBadge.tsx delete mode 100644 packages/extension/src/ui/components/Spinner.tsx delete mode 100644 packages/extension/src/ui/features/accountActivity/AccountActivity.tsx rename packages/extension/src/ui/features/{accountActivityV2 => accountActivity}/ActivityDetailsScreen.tsx (59%) rename packages/extension/src/ui/features/{accountActivityV2 => accountActivity}/ActivityDetailsScreenContainer.tsx (95%) rename packages/extension/src/ui/features/{accountActivityV2 => accountActivity}/ActivityDetailsScreenEmpty.tsx (78%) create mode 100644 packages/extension/src/ui/features/accountActivity/ActivityHistoryContainer.tsx rename packages/extension/src/ui/features/{accountActivityV2/ActivityHistoryContainer.tsx => accountActivity/ActivityListContainer.tsx} (74%) create mode 100644 packages/extension/src/ui/features/accountActivity/EmptyAccountActivity.tsx rename packages/extension/src/ui/features/{accountActivityV2 => accountActivity}/MultisigAccountActivityContainer.tsx (71%) delete mode 100644 packages/extension/src/ui/features/accountActivity/PendingTransactions.tsx delete mode 100644 packages/extension/src/ui/features/accountActivity/TransactionDetailScreen.tsx delete mode 100644 packages/extension/src/ui/features/accountActivity/TransactionDetailWrapper.tsx delete mode 100644 packages/extension/src/ui/features/accountActivity/TransactionListErrorItem.tsx delete mode 100644 packages/extension/src/ui/features/accountActivity/TransactionListItem.tsx rename packages/extension/src/ui/features/accountActivity/{ => legacy}/OffchainSignatureListItem.tsx (79%) rename packages/extension/src/ui/features/accountActivity/{ => legacy}/PendingMultisigTransactions.tsx (83%) create mode 100644 packages/extension/src/ui/features/accountActivity/legacy/ui/TransactionIcon.tsx rename packages/extension/src/ui/features/{accountActivityV2 => accountActivity}/state.ts (78%) delete mode 100644 packages/extension/src/ui/features/accountActivity/ui/ExpandableFieldGroup.tsx delete mode 100644 packages/extension/src/ui/features/accountActivity/ui/LoadMoreTrigger.tsx delete mode 100644 packages/extension/src/ui/features/accountActivity/ui/NFTImage.tsx delete mode 100644 packages/extension/src/ui/features/accountActivity/ui/NFTTitle.tsx delete mode 100644 packages/extension/src/ui/features/accountActivity/ui/SwapAccessory.tsx delete mode 100644 packages/extension/src/ui/features/accountActivity/ui/SwapTransactionIcon.tsx delete mode 100644 packages/extension/src/ui/features/accountActivity/ui/TransactionCallDataBottomSheet.tsx delete mode 100644 packages/extension/src/ui/features/accountActivity/ui/TransferAccessory.tsx delete mode 100644 packages/extension/src/ui/features/accountActivity/ui/TransferTitle.tsx create mode 100644 packages/extension/src/ui/features/accountActivity/useActivityTabWithRestoreScrollState.ts delete mode 100644 packages/extension/src/ui/features/accountActivity/useArgentExplorer.ts delete mode 100644 packages/extension/src/ui/features/accountActivity/useTransactionFees.test.ts delete mode 100644 packages/extension/src/ui/features/accountActivity/useTransactionFees.ts delete mode 100644 packages/extension/src/ui/features/accountActivity/useTransactionNonce.ts delete mode 100644 packages/extension/src/ui/features/accountActivityV2/AccountActivityContainerV2.tsx delete mode 100644 packages/extension/src/ui/features/accountActivityV2/EmptyAccountActivity.tsx delete mode 100644 packages/extension/src/ui/features/accountNfts/NftThumbnailImage.tsx create mode 100644 packages/extension/src/ui/features/accountNfts/WalletNftsTabContainer.tsx delete mode 100644 packages/extension/src/ui/features/accountTokens/AccountBanners.tsx delete mode 100644 packages/extension/src/ui/features/accountTokens/AccountBannersContainer.tsx delete mode 100644 packages/extension/src/ui/features/accountTokens/ActivateMultisigBanner.tsx create mode 100644 packages/extension/src/ui/features/accountTokens/HiddenAndSpamTokensScreen.tsx create mode 100644 packages/extension/src/ui/features/accountTokens/HiddenAndSpamTokensScreenContainer.tsx create mode 100644 packages/extension/src/ui/features/accountTokens/HideTokenListItem.tsx delete mode 100644 packages/extension/src/ui/features/accountTokens/PrettyAccountBalance.tsx create mode 100644 packages/extension/src/ui/features/accountTokens/PrettyBalance.tsx delete mode 100644 packages/extension/src/ui/features/accountTokens/SaveRecoverySeedphraseBanner.tsx rename packages/extension/src/ui/features/accountTokens/{AccountBannersAndTokenListContainer.tsx => TokenListContainer.tsx} (68%) delete mode 100644 packages/extension/src/ui/features/accountTokens/UpgradeBanner.tsx create mode 100644 packages/extension/src/ui/features/accountTokens/WalletCoinsTabContainer.tsx delete mode 100644 packages/extension/src/ui/features/accountTokens/banner/assets/airdrop@3x.png delete mode 100644 packages/extension/src/ui/features/accountTokens/banner/assets/airdropHalted@3x.png create mode 100644 packages/extension/src/ui/features/accountTokens/useHasNonZeroBalance.ts delete mode 100644 packages/extension/src/ui/features/accountTokens/usePrettyAccountBalance.ts create mode 100644 packages/extension/src/ui/features/accountTokens/usePrettyBalance.ts delete mode 100644 packages/extension/src/ui/features/accountTokens/warning/AccountDeprecatedBanner.tsx delete mode 100644 packages/extension/src/ui/features/accountTokens/warning/AccountOwnerBanner.tsx create mode 100644 packages/extension/src/ui/features/accounts/AccountList.tsx create mode 100644 packages/extension/src/ui/features/accounts/AccountListContainer.tsx delete mode 100644 packages/extension/src/ui/features/accounts/AccountListFooterContainer.tsx create mode 100644 packages/extension/src/ui/features/accounts/AccountListItemEditAccessory.tsx delete mode 100644 packages/extension/src/ui/features/accounts/AccountListScreen.tsx delete mode 100644 packages/extension/src/ui/features/accounts/AccountNavigationBar.test.tsx delete mode 100644 packages/extension/src/ui/features/accounts/AccountNavigationBar.tsx delete mode 100644 packages/extension/src/ui/features/accounts/AccountNavigationBarContainer.tsx delete mode 100644 packages/extension/src/ui/features/accounts/SmartAccountDetailedDescription.tsx create mode 100644 packages/extension/src/ui/features/accounts/SmartAccountDetailsDescription.tsx delete mode 100644 packages/extension/src/ui/features/accounts/SmartAccountDetailsDescriptionImproved.tsx delete mode 100644 packages/extension/src/ui/features/accounts/accountMetadata.state.ts delete mode 100644 packages/extension/src/ui/features/accounts/accountMetadata.test.ts delete mode 100644 packages/extension/src/ui/features/accounts/accountNavigationBar.model.ts delete mode 100644 packages/extension/src/ui/features/accounts/hideOrDeleteAccountConfirmScreen.model.ts delete mode 100644 packages/extension/src/ui/features/accounts/switchAccount.ts create mode 100644 packages/extension/src/ui/features/accounts/usePartitionedAccountsByType.ts create mode 100644 packages/extension/src/ui/features/accounts/useSortedAccounts.test.ts create mode 100644 packages/extension/src/ui/features/accounts/useSortedAccounts.ts delete mode 100644 packages/extension/src/ui/features/actions/AccountAddress.tsx rename packages/extension/src/ui/features/actions/{ActionScreen.tsx => ActionScreenContainer.tsx} (94%) delete mode 100644 packages/extension/src/ui/features/actions/connectDapp/useDappDisplayAttributes.ts create mode 100644 packages/extension/src/ui/features/actions/connectDapp/useIsInfluenceDapp.ts delete mode 100644 packages/extension/src/ui/features/actions/feeEstimation/DeployAccountFeeEstimation.tsx delete mode 100644 packages/extension/src/ui/features/actions/feeEstimation/ui/TokenPickerScreen.tsx delete mode 100644 packages/extension/src/ui/features/actions/feeEstimation/useEstimatedFees.ts delete mode 100644 packages/extension/src/ui/features/actions/transaction/ApproveTransactionScreen/DappHeader/DappHeaderArgentX.tsx delete mode 100644 packages/extension/src/ui/features/actions/transaction/ApproveTransactionScreen/DappHeader/TransactionIcon/ActivateAccountIcon.tsx delete mode 100644 packages/extension/src/ui/features/actions/transaction/ApproveTransactionScreen/DappHeader/TransactionIcon/ActivateMultisigIcon.tsx delete mode 100644 packages/extension/src/ui/features/actions/transaction/ApproveTransactionScreen/DappHeader/TransactionIcon/AddOwnerIcon.tsx delete mode 100644 packages/extension/src/ui/features/actions/transaction/ApproveTransactionScreen/DappHeader/TransactionIcon/DeclareTransactionIcon.tsx delete mode 100644 packages/extension/src/ui/features/actions/transaction/ApproveTransactionScreen/DappHeader/TransactionIcon/DowngradeSmartAccountIcon.tsx delete mode 100644 packages/extension/src/ui/features/actions/transaction/ApproveTransactionScreen/DappHeader/TransactionIcon/IconWrapper.tsx delete mode 100644 packages/extension/src/ui/features/actions/transaction/ApproveTransactionScreen/DappHeader/TransactionIcon/NftTransactionIcon.tsx delete mode 100644 packages/extension/src/ui/features/actions/transaction/ApproveTransactionScreen/DappHeader/TransactionIcon/RemoveOwnerIcon.tsx delete mode 100644 packages/extension/src/ui/features/actions/transaction/ApproveTransactionScreen/DappHeader/TransactionIcon/ReplaceOwnerIcon.tsx delete mode 100644 packages/extension/src/ui/features/actions/transaction/ApproveTransactionScreen/DappHeader/TransactionIcon/SendTransactionIcon.tsx delete mode 100644 packages/extension/src/ui/features/actions/transaction/ApproveTransactionScreen/DappHeader/TransactionIcon/SwapTransactionIcon.tsx delete mode 100644 packages/extension/src/ui/features/actions/transaction/ApproveTransactionScreen/DappHeader/TransactionIcon/UnknownDappIcon.tsx delete mode 100644 packages/extension/src/ui/features/actions/transaction/ApproveTransactionScreen/DappHeader/TransactionIcon/UnknownTokenIcon.tsx delete mode 100644 packages/extension/src/ui/features/actions/transaction/ApproveTransactionScreen/DappHeader/TransactionIcon/UpdateThresholdIcon.tsx delete mode 100644 packages/extension/src/ui/features/actions/transaction/ApproveTransactionScreen/DappHeader/TransactionIcon/UpgradeSmartAccountIcon.tsx delete mode 100644 packages/extension/src/ui/features/actions/transaction/ApproveTransactionScreen/DappHeader/TransactionIcon/VerifiedDappIcon.tsx delete mode 100644 packages/extension/src/ui/features/actions/transaction/ApproveTransactionScreen/DappHeader/TransactionIcon/index.test.tsx delete mode 100644 packages/extension/src/ui/features/actions/transaction/ApproveTransactionScreen/DappHeader/TransactionIcon/index.tsx delete mode 100644 packages/extension/src/ui/features/actions/transaction/ApproveTransactionScreen/DappHeader/TransactionTitleArgentX.tsx delete mode 100644 packages/extension/src/ui/features/actions/transaction/ApproveTransactionScreen/TransactionActions.tsx delete mode 100644 packages/extension/src/ui/features/actions/transaction/fields/AccountAddressField.tsx delete mode 100644 packages/extension/src/ui/features/actions/transaction/fields/ContractField.tsx delete mode 100644 packages/extension/src/ui/features/actions/transaction/fields/DappContractField.tsx delete mode 100644 packages/extension/src/ui/features/actions/transaction/fields/FeeField.tsx delete mode 100644 packages/extension/src/ui/features/actions/transaction/fields/MaybeDappContractField.tsx delete mode 100644 packages/extension/src/ui/features/actions/transaction/fields/ParameterField.tsx delete mode 100644 packages/extension/src/ui/features/actions/transaction/fields/TokenField.tsx create mode 100644 packages/extension/src/ui/features/actions/transaction/getTransactionIcon.test.ts create mode 100644 packages/extension/src/ui/features/actions/transaction/getTransactionIcon.tsx create mode 100644 packages/extension/src/ui/features/actions/transaction/getTransactionTitle.test.ts create mode 100644 packages/extension/src/ui/features/actions/transaction/getTransactionTitle.tsx delete mode 100644 packages/extension/src/ui/features/actions/transaction/useErc721Transfers.ts delete mode 100644 packages/extension/src/ui/features/actions/transaction/useTransactionSimulatedData.ts delete mode 100644 packages/extension/src/ui/features/actions/transaction/useTransactionSimulation.ts rename packages/extension/src/ui/features/actions/transactionV2/{FeeEstimationContainerV2.tsx => FeeEstimationContainer.tsx} (78%) create mode 100644 packages/extension/src/ui/features/actions/transactionV2/TransactionActionScreenSkeleton.tsx delete mode 100644 packages/extension/src/ui/features/actions/transactionV2/header/AccountDetailsNavigationContainer.tsx delete mode 100644 packages/extension/src/ui/features/actions/transactionV2/header/NavigationBarAccountDetails.tsx delete mode 100644 packages/extension/src/ui/features/actions/transactionV2/header/NavigationBarAccountDetailsContainer.tsx delete mode 100644 packages/extension/src/ui/features/actions/transactionV2/useTransactionHash.ts create mode 100644 packages/extension/src/ui/features/banners/AccountBanners.tsx create mode 100644 packages/extension/src/ui/features/banners/AccountBannersContainer.tsx create mode 100644 packages/extension/src/ui/features/banners/AccountDeprecatedBanner.tsx create mode 100644 packages/extension/src/ui/features/banners/AccountOwnerBanner.tsx create mode 100644 packages/extension/src/ui/features/banners/Banner.tsx create mode 100644 packages/extension/src/ui/features/banners/EscapeBanner.tsx create mode 100644 packages/extension/src/ui/features/banners/EscapeBannerContainer.tsx create mode 100644 packages/extension/src/ui/features/banners/MultisigBanner.tsx create mode 100644 packages/extension/src/ui/features/banners/MultisigBannerContainer.tsx create mode 100644 packages/extension/src/ui/features/banners/PromoStakingBanner.tsx create mode 100644 packages/extension/src/ui/features/banners/PromoStakingBannerContainer.tsx create mode 100644 packages/extension/src/ui/features/banners/SaveRecoverySeedphraseBanner.tsx create mode 100644 packages/extension/src/ui/features/banners/StatusMessageBanner.tsx create mode 100644 packages/extension/src/ui/features/banners/StatusMessageBannerContainer.tsx create mode 100644 packages/extension/src/ui/features/banners/UpgradeBanner.tsx rename packages/extension/src/ui/features/{accountTokens => banners}/UpgradeBannerContainer.tsx (56%) create mode 100644 packages/extension/src/ui/features/banners/useBanners.tsx create mode 100644 packages/extension/src/ui/features/defi/TokenInfo.tsx create mode 100644 packages/extension/src/ui/features/defi/WalletDefiTabContainer.tsx create mode 100644 packages/extension/src/ui/features/defi/defiDecomposition/CollateralizedDebtStatus.tsx create mode 100644 packages/extension/src/ui/features/defi/defiDecomposition/ConcentratedLiquidityStatus.test.ts create mode 100644 packages/extension/src/ui/features/defi/defiDecomposition/ConcentratedLiquidityStatus.tsx create mode 100644 packages/extension/src/ui/features/defi/defiDecomposition/DefiDecomposition.tsx create mode 100644 packages/extension/src/ui/features/defi/defiDecomposition/DefiDecompositionContainer.tsx create mode 100644 packages/extension/src/ui/features/defi/defiDecomposition/DefiIcon.tsx create mode 100644 packages/extension/src/ui/features/defi/defiDecomposition/DefiPosition.tsx create mode 100644 packages/extension/src/ui/features/defi/defiDecomposition/DefiPositionBalance.tsx create mode 100644 packages/extension/src/ui/features/defi/defiDecomposition/DefiPositionDescription.tsx create mode 100644 packages/extension/src/ui/features/defi/defiDecomposition/DefiPositionSkeleton.tsx create mode 100644 packages/extension/src/ui/features/defi/defiDecomposition/DefiPositionSubtitle.tsx create mode 100644 packages/extension/src/ui/features/defi/defiDecomposition/DefiPositionTitle.tsx create mode 100644 packages/extension/src/ui/features/defi/defiDecomposition/StrkDelegatedStakingStatus.tsx create mode 100644 packages/extension/src/ui/features/defi/defiDecomposition/positionDetails/DefiPositionAlertBanner.tsx create mode 100644 packages/extension/src/ui/features/defi/defiDecomposition/positionDetails/DefiPositionDetailsActions.tsx create mode 100644 packages/extension/src/ui/features/defi/defiDecomposition/positionDetails/DefiPositionDetailsBalance.tsx create mode 100644 packages/extension/src/ui/features/defi/defiDecomposition/positionDetails/DefiPositionDetailsBreakdownInfo.tsx create mode 100644 packages/extension/src/ui/features/defi/defiDecomposition/positionDetails/DefiPositionDetailsScreen.tsx create mode 100644 packages/extension/src/ui/features/defi/defiDecomposition/positionDetails/DefiPositionDetailsScreenContainer.tsx create mode 100644 packages/extension/src/ui/features/defi/defiDecomposition/positionDetails/DefiPositionDetailsTitle.tsx create mode 100644 packages/extension/src/ui/features/defi/defiDecomposition/positionDetails/DefiPositionDetailsTokensInfo.tsx create mode 100644 packages/extension/src/ui/features/defi/defiDecomposition/positionDetails/useDefiPositionBreakdownInfo.tsx create mode 100644 packages/extension/src/ui/features/defi/staking/InvestmentInfo.tsx create mode 100644 packages/extension/src/ui/features/defi/staking/LiquidStakingProviderSelectScreen.tsx create mode 100644 packages/extension/src/ui/features/defi/staking/LiquidStakingProviderSelectScreenContainer.tsx create mode 100644 packages/extension/src/ui/features/defi/staking/NativeStakingProviderSelectScreen.tsx create mode 100644 packages/extension/src/ui/features/defi/staking/NativeStakingProviderSelectScreenContainer.tsx create mode 100644 packages/extension/src/ui/features/defi/staking/NativeStakingScreen.tsx create mode 100644 packages/extension/src/ui/features/defi/staking/NativeStakingScreenContainer.tsx create mode 100644 packages/extension/src/ui/features/defi/staking/StakerIcon.tsx create mode 100644 packages/extension/src/ui/features/defi/staking/StakingButtonCell.tsx create mode 100644 packages/extension/src/ui/features/defi/staking/StakingScreenContainer.tsx create mode 100644 packages/extension/src/ui/features/defi/staking/StakingWarningBox.tsx create mode 100644 packages/extension/src/ui/features/defi/staking/UnstakingScreen.tsx create mode 100644 packages/extension/src/ui/features/defi/staking/UnstakingScreenContainer.tsx create mode 100644 packages/extension/src/ui/features/defi/staking/WithdrawWarningModal.tsx create mode 100644 packages/extension/src/ui/features/defi/staking/hooks/useMaxFeeForStaking.ts create mode 100644 packages/extension/src/ui/features/defi/staking/hooks/useStrkDelegatedStakingInvestments.ts delete mode 100644 packages/extension/src/ui/features/dev/useDevStorageUI.tsx create mode 100644 packages/extension/src/ui/features/funding/constants.ts create mode 100644 packages/extension/src/ui/features/importedAccounts/ImportErrorBottomModal.tsx create mode 100644 packages/extension/src/ui/features/importedAccounts/ImportPrivateKeyScreen.tsx create mode 100644 packages/extension/src/ui/features/ledger/LedgerReconnectSuccess.tsx create mode 100644 packages/extension/src/ui/features/ledger/ReplaceMultisigOwnerWithLedger/ReplaceMultisigOwnerWithLedger.tsx delete mode 100644 packages/extension/src/ui/features/multisig/MultisigBanner.tsx delete mode 100644 packages/extension/src/ui/features/multisig/RejectOnChainModal.tsx create mode 100644 packages/extension/src/ui/features/multisig/hooks/useReplaceMultisigOwner.ts create mode 100644 packages/extension/src/ui/features/navigation/AccountDetailsNavigationBar.tsx create mode 100644 packages/extension/src/ui/features/navigation/AccountDetailsNavigationBarContainer.tsx create mode 100644 packages/extension/src/ui/features/navigation/AccountDetailsNavigationContainer.tsx create mode 100644 packages/extension/src/ui/features/navigation/AccountNavigationBar.tsx create mode 100644 packages/extension/src/ui/features/navigation/AccountNavigationBarContainer.tsx create mode 100644 packages/extension/src/ui/features/navigation/LedgerStatusText.tsx rename packages/extension/src/ui/features/{networks/NetworkSwitcher => navigation}/NetworkSwitcher.test.tsx (85%) rename packages/extension/src/ui/features/{networks/NetworkSwitcher => navigation}/NetworkSwitcherButton.tsx (56%) create mode 100644 packages/extension/src/ui/features/navigation/NetworkSwitcherContainer.tsx create mode 100644 packages/extension/src/ui/features/navigation/NetworkSwitcherList.tsx create mode 100644 packages/extension/src/ui/features/navigation/SettingsBarIconButton.tsx delete mode 100644 packages/extension/src/ui/features/networks/NetworkSwitcher/NetworkSwitcherContainer.tsx delete mode 100644 packages/extension/src/ui/features/networks/NetworkSwitcher/NetworkSwitcherList.tsx rename packages/extension/src/ui/features/onboarding/{OnboardingSmartAccountEmailScreen.test.tsx => OnboardingSmartAccountEmailScreenContainer.test.tsx} (69%) create mode 100644 packages/extension/src/ui/features/onboarding/OnboardingSmartAccountEmailScreenContainer.tsx rename packages/extension/src/ui/features/onboarding/{OnboardingSmartAccountOTPScreen.test.tsx => OnboardingSmartAccountOTPScreenContainer.test.tsx} (84%) create mode 100644 packages/extension/src/ui/features/onboarding/OnboardingSmartAccountOTPScreenContainer.tsx delete mode 100644 packages/extension/src/ui/features/onboarding/ui/ArgentLinksRow.tsx delete mode 100644 packages/extension/src/ui/features/onboarding/ui/KeyAsset.tsx delete mode 100644 packages/extension/src/ui/features/onboarding/ui/LockAsset.tsx delete mode 100644 packages/extension/src/ui/features/onboarding/ui/MobileAsset.tsx create mode 100644 packages/extension/src/ui/features/onboarding/ui/OnboardingContainer.tsx create mode 100644 packages/extension/src/ui/features/onboarding/ui/OnboardingFinishArgentLinksRow.tsx create mode 100644 packages/extension/src/ui/features/onboarding/ui/OnboardingFinishSmartAccountFeaturesRow.tsx delete mode 100644 packages/extension/src/ui/features/onboarding/ui/OnboardingSmartAccountFeaturesRow.tsx delete mode 100644 packages/extension/src/ui/features/onboarding/ui/OnchainRecoveryAsset.tsx create mode 100644 packages/extension/src/ui/features/root/RootTabsEmptyScreenContainer.tsx create mode 100644 packages/extension/src/ui/features/settings/account/AccountEditButtons/AccountEditButtonsImported.tsx rename packages/extension/src/ui/features/settings/{developerSettings/DeveloperSettingsScreen.tsx => advanced/AdvancedSettingsScreen.tsx} (58%) create mode 100644 packages/extension/src/ui/features/settings/advanced/AdvancedSettingsScreenContainer.tsx rename packages/extension/src/ui/features/settings/{developerSettings => advanced}/betaFeatures/BetaFeaturesSettingsScreen.tsx (87%) rename packages/extension/src/ui/features/settings/{developerSettings => advanced}/betaFeatures/BetaFeaturesSettingsScreenContainer.tsx (84%) rename packages/extension/src/ui/features/settings/{developerSettings => advanced}/clearLocalStorage/ClearLocalStorageScreen.tsx (87%) rename packages/extension/src/ui/features/settings/{developerSettings => advanced}/clearLocalStorage/useClearLocalStorage.tsx (100%) rename packages/extension/src/ui/features/settings/{developerSettings => advanced}/deploymentData/DeploymentDataScreen.tsx (85%) rename packages/extension/src/ui/features/settings/{developerSettings => advanced}/downloadLogs/DownloadLogsScreen.test.tsx (96%) rename packages/extension/src/ui/features/settings/{developerSettings => advanced}/downloadLogs/DownloadLogsScreen.tsx (91%) rename packages/extension/src/ui/features/settings/{developerSettings => advanced}/downloadLogs/utils.ts (100%) rename packages/extension/src/ui/features/settings/{developerSettings => advanced}/experimental/ExperimentalSettingsScreen.tsx (95%) rename packages/extension/src/ui/features/settings/{developerSettings => advanced}/experimental/ExperimentalSettingsScreenContainer.tsx (96%) rename packages/extension/src/ui/features/settings/{developerSettings => advanced}/manageNetworks/NetworkSettingsEditScreen.tsx (94%) create mode 100644 packages/extension/src/ui/features/settings/advanced/manageNetworks/NetworkSettingsFormScreen.tsx rename packages/extension/src/ui/features/settings/{developerSettings => advanced}/manageNetworks/NetworkSettingsFormScreenContainer.tsx (93%) rename packages/extension/src/ui/features/settings/{developerSettings => advanced}/manageNetworks/NetworkSettingsScreen.tsx (90%) rename packages/extension/src/ui/features/settings/{developerSettings => advanced}/manageNetworks/NetworkSettingsScreenContainer.tsx (82%) rename packages/extension/src/ui/features/settings/{developerSettings => advanced}/manageNetworks/slugify.ts (100%) rename packages/extension/src/ui/features/settings/{developerSettings => advanced}/manageNetworks/validateRemoveNetwork.ts (100%) delete mode 100644 packages/extension/src/ui/features/settings/developerSettings/DeveloperSettingsScreenContainer.tsx delete mode 100644 packages/extension/src/ui/features/settings/developerSettings/manageNetworks/NetworkSettingsFormScreen.tsx delete mode 100644 packages/extension/src/ui/features/settings/developerSettings/smartContractDevelopment/ClassHashInputActions.tsx delete mode 100644 packages/extension/src/ui/features/settings/developerSettings/smartContractDevelopment/ClassHashOption.tsx delete mode 100644 packages/extension/src/ui/features/settings/developerSettings/smartContractDevelopment/DeclareOrDeployContractSuccessScreen.tsx delete mode 100644 packages/extension/src/ui/features/settings/developerSettings/smartContractDevelopment/DeclareOrDeployContractSuccessScreenContainer.tsx delete mode 100644 packages/extension/src/ui/features/settings/developerSettings/smartContractDevelopment/DeclareSmartContractForm.tsx delete mode 100644 packages/extension/src/ui/features/settings/developerSettings/smartContractDevelopment/DeclareSmartContractScreen.tsx delete mode 100644 packages/extension/src/ui/features/settings/developerSettings/smartContractDevelopment/DeploySmartContractForm.tsx delete mode 100644 packages/extension/src/ui/features/settings/developerSettings/smartContractDevelopment/DeploySmartContractParameters.tsx delete mode 100644 packages/extension/src/ui/features/settings/developerSettings/smartContractDevelopment/DeploySmartContractParametersContainer.tsx delete mode 100644 packages/extension/src/ui/features/settings/developerSettings/smartContractDevelopment/DeploySmartContractScreen.tsx delete mode 100644 packages/extension/src/ui/features/settings/developerSettings/smartContractDevelopment/SelectOptionAccount.tsx delete mode 100644 packages/extension/src/ui/features/settings/developerSettings/smartContractDevelopment/SmartContractDevelopmentScreen.tsx delete mode 100644 packages/extension/src/ui/features/settings/developerSettings/smartContractDevelopment/udc.state.ts delete mode 100644 packages/extension/src/ui/features/settings/developerSettings/smartContractDevelopment/ui/ContractWithClassHash.tsx delete mode 100644 packages/extension/src/ui/features/settings/developerSettings/smartContractDevelopment/ui/FileInputButton.tsx delete mode 100644 packages/extension/src/ui/features/settings/developerSettings/smartContractDevelopment/useConstructorParams.tsx delete mode 100644 packages/extension/src/ui/features/settings/developerSettings/smartContractDevelopment/useFormSelects.tsx create mode 100644 packages/extension/src/ui/features/settings/preferences/IdProviderSettingsScreen.tsx create mode 100644 packages/extension/src/ui/features/settings/preferences/IdProviderSettingsScreenContainer.tsx rename packages/extension/src/ui/features/settings/{securityAndPrivacy/SecurityAndPrivacySettingsScreen.tsx => privacy/PrivacySettingsScreen.tsx} (57%) rename packages/extension/src/ui/features/settings/{securityAndPrivacy/SecurityAndPrivacySettingsScreenContainer.tsx => privacy/PrivacySettingsScreenContainer.tsx} (57%) rename packages/extension/src/ui/features/settings/{securityAndPrivacy => securityAndRecovery}/AutoLockTimerSettingsScreen.tsx (95%) rename packages/extension/src/ui/features/settings/{securityAndPrivacy => securityAndRecovery}/AutoLockTimerSettingsScreenContainer.tsx (96%) rename packages/extension/src/ui/features/settings/{securityAndPrivacy => securityAndRecovery}/BeforeYouContinueScreen.tsx (100%) create mode 100644 packages/extension/src/ui/features/settings/securityAndRecovery/SecurityAndRecoverySettingsScreen.tsx create mode 100644 packages/extension/src/ui/features/settings/securityAndRecovery/SecurityAndRecoverySettingsScreenContainer.tsx rename packages/extension/src/ui/features/settings/{securityAndPrivacy => securityAndRecovery}/SeedSettingsScreen.tsx (92%) rename packages/extension/src/ui/features/settings/{securityAndPrivacy => securityAndRecovery}/SeedSettingsScreenContainer.tsx (91%) delete mode 100644 packages/extension/src/ui/features/smartAccount/escape/EscapeBanner.tsx create mode 100644 packages/extension/src/ui/features/smartAccount/escape/getEscapeDisplayAttributes.tsx delete mode 100644 packages/extension/src/ui/features/statusMessage/StatusMessageBanner.tsx delete mode 100644 packages/extension/src/ui/features/statusMessage/StatusMessageBannerContainer.tsx create mode 100644 packages/extension/src/ui/features/statusMessage/StatusMessageFullScreenContainer.tsx delete mode 100644 packages/extension/src/ui/features/statusMessage/getColorForLevel.tsx create mode 100644 packages/extension/src/ui/features/tokenDetails/OptionsMenu.tsx create mode 100644 packages/extension/src/ui/features/tokenDetails/TokenDetailsChartContainer.tsx create mode 100644 packages/extension/src/ui/features/tokenDetails/TokenDetailsScreen.tsx create mode 100644 packages/extension/src/ui/features/tokenDetails/config.ts create mode 100644 packages/extension/src/ui/features/tokenDetails/hooks/useTokenActivities.tsx create mode 100644 packages/extension/src/ui/features/tokenDetails/hooks/useTokenGraphInfo.tsx create mode 100644 packages/extension/src/ui/features/userReview/StarIcon.tsx create mode 100644 packages/extension/src/ui/features/userReview/StarRating.tsx create mode 100644 packages/extension/src/ui/hooks/useCapsLockStatus.ts create mode 100644 packages/extension/src/ui/hooks/useParseQuery.test.ts create mode 100644 packages/extension/src/ui/hooks/useParseQuery.ts create mode 100644 packages/extension/src/ui/hooks/useTabIndexWithHash.ts create mode 100644 packages/extension/src/ui/hooks/useTokenAmountToCcyCallback.ts create mode 100644 packages/extension/src/ui/router/index.tsx create mode 100644 packages/extension/src/ui/services/account/ClientAccountService.test.ts create mode 100644 packages/extension/src/ui/services/importAccount/ClientImportAccountService.ts create mode 100644 packages/extension/src/ui/services/importAccount/IClientImportAccount.ts create mode 100644 packages/extension/src/ui/services/importAccount/index.ts create mode 100644 packages/extension/src/ui/services/investments/InvestmentService.ts create mode 100644 packages/extension/src/ui/services/investments/index.ts create mode 100644 packages/extension/src/ui/services/knownDapps/types.ts create mode 100644 packages/extension/src/ui/services/knownDapps/useDappDisplayAttributes.ts create mode 100644 packages/extension/src/ui/services/knownDapps/useDappFromKnownDappsByContractAddress.ts create mode 100644 packages/extension/src/ui/services/knownDapps/useDappFromKnownDappsByHost.ts create mode 100644 packages/extension/src/ui/services/knownDapps/useDappFromKnownDappsByName.ts create mode 100644 packages/extension/src/ui/services/knownDapps/useKnownDapps.ts delete mode 100644 packages/extension/src/ui/services/onboarding/useOnboardingExperiment.ts create mode 100644 packages/extension/src/ui/services/staking/StakingService.ts create mode 100644 packages/extension/src/ui/services/staking/index.ts create mode 100644 packages/extension/src/ui/services/tokenDetails/ClientTokenDetailsService.ts create mode 100644 packages/extension/src/ui/services/tokenDetails/index.ts rename packages/extension/src/ui/services/tokens/{test/index.test.ts => ClientTokenService.test.ts} (95%) delete mode 100644 packages/extension/src/ui/theme/Typography.tsx delete mode 100644 packages/extension/src/ui/theme/index.tsx delete mode 100644 packages/extension/src/ui/theme/styled.d.ts create mode 100644 packages/extension/src/ui/views/investments.ts create mode 100644 packages/extension/src/ui/views/staking.ts create mode 100644 packages/extension/src/ui/views/transactionHashes.ts create mode 100644 packages/extension/test/accountIdSchema.test.ts create mode 100644 packages/extension/test/accountsWithoutId.mock.ts delete mode 100644 packages/extension/test/knownDapps.test.ts delete mode 100644 packages/extension/test/url.test.ts create mode 100644 packages/storybook/src/decorators/db/argentDbDecorator.tsx create mode 100644 packages/storybook/src/decorators/db/tokenPrices.ts create mode 100644 packages/storybook/src/decorators/db/tokens.ts create mode 100644 packages/storybook/src/decorators/db/tokensInfo.ts delete mode 100644 packages/storybook/src/decorators/depreactedMuiDecorator.tsx create mode 100644 packages/storybook/src/features/accountNfts/AccountCollections.stories.tsx create mode 100644 packages/storybook/src/features/accountNfts/__fixtures__/account-collections.json create mode 100644 packages/storybook/src/features/accountTokens/AccountTokensButtons.stories.tsx create mode 100644 packages/storybook/src/features/accountTokens/AccountTokensButtonsSkeleton.stories.tsx delete mode 100644 packages/storybook/src/features/accounts/AccountActivity.stories.tsx rename packages/storybook/src/features/accounts/{AccountListScreen.stories.tsx => AccountList.stories.tsx} (93%) delete mode 100644 packages/storybook/src/features/accounts/AccountNavigationBar.stories.tsx create mode 100644 packages/storybook/src/features/accounts/ImportPrivateKeyScreen.stories.tsx delete mode 100644 packages/storybook/src/features/accounts/PendingTransactions.stories.tsx delete mode 100644 packages/storybook/src/features/accounts/TransactionCallDataBottomSheet.stories.tsx delete mode 100644 packages/storybook/src/features/accounts/TransactionDetailExplorer.stories.tsx delete mode 100644 packages/storybook/src/features/accounts/TransactionDetailRaw.stories.tsx delete mode 100644 packages/storybook/src/features/accounts/TransactionDetailWrapped.tsx delete mode 100644 packages/storybook/src/features/accounts/TransactionListItem.stories.tsx delete mode 100644 packages/storybook/src/features/accounts/__fixtures__/transactions-pending.json delete mode 100644 packages/storybook/src/features/accounts/__fixtures__/transactions.json delete mode 100644 packages/storybook/src/features/actions/transaction/KnownDappButton.stories.tsx delete mode 100644 packages/storybook/src/features/actions/transaction/TokenField.stories.tsx delete mode 100644 packages/storybook/src/features/actions/transaction/TransactionActions.stories.tsx delete mode 100644 packages/storybook/src/features/actions/transactionV2/NavigationBarAccountDetails.stories.tsx delete mode 100644 packages/storybook/src/features/actions/transactionV2/TokenPickerScreen.stories.tsx create mode 100644 packages/storybook/src/features/banners/AccountBanners.stories.tsx create mode 100644 packages/storybook/src/features/banners/AccountDeprecatedBanner.stories.tsx create mode 100644 packages/storybook/src/features/banners/AccountOwnerBanner.stories.tsx create mode 100644 packages/storybook/src/features/banners/Banner.stories.tsx create mode 100644 packages/storybook/src/features/banners/EscapeBanner.stories.tsx create mode 100644 packages/storybook/src/features/banners/MultisigBanner.stories.tsx create mode 100644 packages/storybook/src/features/banners/PromoStakingBanner.stories.tsx create mode 100644 packages/storybook/src/features/banners/SaveRecoverySeedphraseBanner.stories.tsx create mode 100644 packages/storybook/src/features/banners/StatusMessageBanner.stories.tsx create mode 100644 packages/storybook/src/features/banners/StatusMessageFullScreen.stories.tsx create mode 100644 packages/storybook/src/features/banners/UpgradeBanner.stories.tsx create mode 100644 packages/storybook/src/features/banners/__fixtures__/danger.json create mode 100644 packages/storybook/src/features/banners/__fixtures__/info.json create mode 100644 packages/storybook/src/features/banners/__fixtures__/null.json create mode 100644 packages/storybook/src/features/banners/__fixtures__/statusMesages.ts create mode 100644 packages/storybook/src/features/banners/__fixtures__/upgrade.json create mode 100644 packages/storybook/src/features/banners/__fixtures__/warning.json create mode 100644 packages/storybook/src/features/defiDecomposition/DefiIcon.stories.tsx create mode 100644 packages/storybook/src/features/defiDecomposition/DefiPosition.stories.tsx create mode 100644 packages/storybook/src/features/defiDecomposition/TokenInfo.stories.tsx create mode 100644 packages/storybook/src/features/defiDecomposition/__fixtures__/parsedDefiDecomposition.ts create mode 100644 packages/storybook/src/features/defiDecomposition/__fixtures__/parsedDefiDecompositionWithUsdValue.ts create mode 100644 packages/storybook/src/features/importedAccounts/ImportPrivateKeyScreen.stories.tsx create mode 100644 packages/storybook/src/features/navigation/AccountDetailsNavigationBar.stories.tsx create mode 100644 packages/storybook/src/features/navigation/AccountNavigationBar.stories.tsx create mode 100644 packages/storybook/src/features/navigation/NetworkSwitcherList.stories.tsx create mode 100644 packages/storybook/src/features/onboarding/OnboardingAccountTypeScreen.stories.tsx create mode 100644 packages/storybook/src/features/onboarding/OnboardingPrivacyScreen.stories.tsx create mode 100644 packages/storybook/src/features/onboarding/OnboardingSmartAccountEmailScreen.stories.tsx create mode 100644 packages/storybook/src/features/onboarding/OnboardingSmartAccountOTPScreen.stories.tsx rename packages/storybook/src/features/settings/{DeveloperSettingsScreen.stories.tsx => AdvancedSettingsScreen.stories.tsx} (59%) delete mode 100644 packages/storybook/src/features/settings/DeclareOrDeployContractSuccessScreen.stories.tsx delete mode 100644 packages/storybook/src/features/settings/DeclareSmartContractForm.stories.tsx delete mode 100644 packages/storybook/src/features/settings/DeploySmartContractForm.stories.tsx rename packages/storybook/src/features/settings/{SecurityAndPrivacySettingsScreen.stories.tsx => SecurityAndRecoverySettingsScreen.stories.tsx} (51%) create mode 100644 packages/storybook/src/features/staking/ProviderSelectScreen.stories.tsx create mode 100644 packages/storybook/src/features/staking/UnstakingScreen.stories.tsx create mode 100644 packages/storybook/src/features/staking/__fixtures__/defi-investments.json create mode 100644 packages/storybook/src/features/staking/__fixtures__/starknet-staking-investments.json delete mode 100644 packages/storybook/src/features/statusMessage/StatusMessageBanner.stories.tsx delete mode 100644 packages/storybook/src/features/statusMessage/StatusMessageFullScreen.stories.tsx delete mode 100644 packages/storybook/src/features/statusMessage/__fixtures__/status-messages.json create mode 100644 packages/storybook/src/ui/components/AppErrorBoundaryFallback.stories.tsx delete mode 100644 packages/storybook/src/ui/components/CopyIconButton.stories.tsx delete mode 100644 packages/storybook/src/ui/components/DappContractField.stories.tsx delete mode 100644 packages/storybook/src/ui/components/InputText.stories.tsx delete mode 100644 packages/storybook/src/ui/components/InputTextArea.stories.tsx delete mode 100644 packages/window/.eslintrc.js delete mode 100644 packages/window/.gitignore delete mode 100644 packages/window/CHANGELOG.md delete mode 100644 packages/window/package.json delete mode 100644 packages/window/src/account.ts delete mode 100644 packages/window/src/eventHandlers.ts delete mode 100644 packages/window/src/index.ts delete mode 100644 packages/window/src/messages/__tests__/relayer.test.ts delete mode 100644 packages/window/src/messages/__tests__/window.test.ts delete mode 100644 packages/window/src/messages/__tests__/windowMock.mock.ts delete mode 100644 packages/window/src/messages/exchange/bidirectional.ts delete mode 100644 packages/window/src/messages/exchange/relayer.ts delete mode 100644 packages/window/src/messages/messenger/index.ts delete mode 100644 packages/window/src/messages/messenger/window.ts delete mode 100644 packages/window/src/starknet.ts delete mode 100644 packages/window/src/types.test.ts delete mode 100644 packages/window/src/types.ts delete mode 100644 packages/window/src/utils/mittx.ts delete mode 100644 packages/window/src/vite-env.d.ts delete mode 100644 packages/window/tsconfig.json delete mode 100644 packages/window/vite.config.ts delete mode 100644 packages/window/vitest.config.ts diff --git a/.eslintignore b/.eslintignore index 528dc4eed..58e5130fd 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1 +1,2 @@ -scripts \ No newline at end of file +scripts +e2e \ No newline at end of file diff --git a/.github/workflows/chromatic.yml b/.github/workflows/chromatic.yml index f7a261ed7..c1a63f3ca 100644 --- a/.github/workflows/chromatic.yml +++ b/.github/workflows/chromatic.yml @@ -11,8 +11,6 @@ jobs: env: SAFE_ENV_VARS: true ARGENT_API_BASE_URL: ${{ vars.ARGENT_API_BASE_URL }} - ARGENT_TESTNET_RPC_URL: ${{ vars.ARGENT_TESTNET_RPC_URL }} - ARGENT_HEALTHCHECK_BASE_URL: ${{ vars.ARGENT_HEALTHCHECK_BASE_URL }} ARGENT_X_STATUS_URL: ${{ vars.ARGENT_X_STATUS_URL }} ARGENT_X_NEWS_URL: ${{ vars.ARGENT_X_NEWS_URL }} ARGENT_X_ENVIRONMENT: "prod" diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 89205977c..5bc17a365 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -5,9 +5,267 @@ on: - develop pull_request: +env: + PNPM_VERSION: 9 + NODE_VERSION: 18.x + jobs: + build-alpha: + runs-on: ubuntu-latest + container: + image: mcr.microsoft.com/playwright:v1.48.2-jammy + strategy: + matrix: + env: [hydrogen] + extension_type: [chrome] + + environment: ${{ matrix.env }} + + env: + # FEATURE flags + FEATURE_PRIVACY_SETTINGS: "true" + FEATURE_EXPERIMENTAL_SETTINGS: "false" + FEATURE_BETA_FEATURES: "false" + FEATURE_BANXA: "true" + FEATURE_LAYERSWAP: "true" + FEATURE_ORBITER: "true" + FEATURE_MULTISIG: "true" + ENABLE_TOKEN_DETAILS: "true" + FEATURE_DEFI_DECOMPOSITION: "true" + + # API URLs + ARGENT_API_BASE_URL: ${{ vars.ARGENT_API_BASE_URL }} + ARGENT_X_STATUS_URL: ${{ vars.ARGENT_X_STATUS_URL }} + ARGENT_X_NEWS_URL: ${{ vars.ARGENT_X_NEWS_URL }} + # API ENVIRONMENT + ARGENT_X_ENVIRONMENT: ${{ matrix.env }} + + # Sentry + SENTRY_DSN: ${{ secrets.SENTRY_DSN }} + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_ORG: ${{ secrets.SENTRY_ORG }} + SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} + SENTRY_ENVIRONMENT: "staging" + + # Misc + RAMP_API_KEY: ${{ secrets.RAMP_API_KEY }} + SAFE_ENV_VARS: false + MULTICALL_MAX_BATCH_SIZE: 20 + NEW_CAIRO_0_ENABLED: false + TOPPER_PEM_KEY: ${{ secrets.TOPPER_PEM_KEY }} + # Refresh intervals + FAST: 20 # 20s + MEDIUM: 60 # 60s + SLOW: 60 * 5 # 5m + VERY_SLOW: 24 * 60 * 60 # 1d + MIN_LEDGER_APP_VERSION: 2.2.1 + + #For testing only + FEE_OVERHEAD: 2 + + steps: + # Setup Project + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + name: Install pnpm + id: pnpm-install + with: + version: ${{ env.PNPM_VERSION }} + run_install: false + + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: "pnpm" + + - name: Get pnpm store directory + id: pnpm-cache + shell: bash + run: | + echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT + + - uses: actions/cache@v4 + name: Setup pnpm cache + with: + path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} + key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store- + + - name: Set Sentry Release name (rc) + run: | + PACKAGE_VERSION=$(grep -m1 '"version":' ./packages/extension/package.json | awk -F: '{ print $2 }' | sed 's/[", ]//g') + echo "SENTRY_RELEASE=${PACKAGE_VERSION}-rc__${GITHUB_SHA}" >> $GITHUB_ENV + + - name: Setup project + run: pnpm run setup + + - name: Build extension for ${{ matrix.extension_type }} + run: | + if [ "${{ matrix.extension_type }}" = "firefox" ]; then + echo "Building extension using manifest v2" + MANIFEST_VERSION=v2 pnpm run build:extension:alpha + else + echo "Building extension using manifest v3" + MANIFEST_VERSION=v3 pnpm run build:extension:alpha + fi + + - name: Check bundle size for ${{ matrix.extension_type }} + run: pnpm check-bundle-size + + - name: Use Cache + uses: actions/cache@v4 + with: + path: ./* + key: ${{ github.sha }}-${{ matrix.extension_type }}-${{ matrix.env }} + + - name: Set filename prefix + run: echo "FILENAME_PREFIX=$(echo argent-x-${{ matrix.env }}-${{ github.ref_name }} | tr / -)" >> $GITHUB_ENV + + - name: Install zip + run: apt-get update && apt-get install -y zip + + - name: Create ${{ matrix.extension_type }} zip + run: (cd ./packages/extension/dist && zip -r "../../../${{ env.FILENAME_PREFIX }}-${{ matrix.extension_type }}-alpha" .) + + - name: Upload ${{ matrix.extension_type }} extension + uses: actions/upload-artifact@v4 + with: + name: ${{ env.FILENAME_PREFIX }}-${{ matrix.extension_type }}-alpha + path: "*-${{ matrix.extension_type }}-alpha.zip" + retention-days: 3 + + build-beta: + runs-on: ubuntu-latest + container: + image: mcr.microsoft.com/playwright:v1.48.2-jammy + strategy: + matrix: + env: [prod] + extension_type: [chrome] + + environment: ${{ matrix.env }} + + env: + # FEATURE flags + FEATURE_PRIVACY_SETTINGS: "true" + FEATURE_EXPERIMENTAL_SETTINGS: "false" + FEATURE_BETA_FEATURES: "false" + FEATURE_BANXA: "true" + FEATURE_LAYERSWAP: "true" + FEATURE_ORBITER: "true" + FEATURE_MULTISIG: "true" + ENABLE_TOKEN_DETAILS: "true" + FEATURE_DEFI_DECOMPOSITION: "true" + + # API URLs + ARGENT_API_BASE_URL: ${{ vars.ARGENT_API_BASE_URL }} + ARGENT_X_STATUS_URL: ${{ vars.ARGENT_X_STATUS_URL }} + ARGENT_X_NEWS_URL: ${{ vars.ARGENT_X_NEWS_URL }} + # API ENVIRONMENT + ARGENT_X_ENVIRONMENT: ${{ matrix.env }} + + # Sentry + SENTRY_DSN: ${{ secrets.SENTRY_DSN }} + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_ORG: ${{ secrets.SENTRY_ORG }} + SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} + SENTRY_ENVIRONMENT: "staging" + + # Misc + RAMP_API_KEY: ${{ secrets.RAMP_API_KEY }} + SAFE_ENV_VARS: false + MULTICALL_MAX_BATCH_SIZE: 20 + NEW_CAIRO_0_ENABLED: false + TOPPER_PEM_KEY: ${{ secrets.TOPPER_PEM_KEY }} + # Refresh intervals + FAST: 20 # 20s + MEDIUM: 60 # 60s + SLOW: 60 * 5 # 5m + VERY_SLOW: 24 * 60 * 60 # 1d + MIN_LEDGER_APP_VERSION: 2.2.1 + + #For testing only + FEE_OVERHEAD: 2 + + steps: + # Setup Project + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + name: Install pnpm + id: pnpm-install + with: + version: ${{ env.PNPM_VERSION }} + run_install: false + + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: "pnpm" + + - name: Get pnpm store directory + id: pnpm-cache + shell: bash + run: | + echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT + + - uses: actions/cache@v4 + name: Setup pnpm cache + with: + path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} + key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store- + + - name: Set Sentry Release name (rc) + run: | + PACKAGE_VERSION=$(grep -m1 '"version":' ./packages/extension/package.json | awk -F: '{ print $2 }' | sed 's/[", ]//g') + echo "SENTRY_RELEASE=${PACKAGE_VERSION}-rc__${GITHUB_SHA}" >> $GITHUB_ENV + + - name: Setup project + run: pnpm run setup + + - name: Build extension for ${{ matrix.extension_type }} + run: | + if [ "${{ matrix.extension_type }}" = "firefox" ]; then + echo "Building extension using manifest v2" + MANIFEST_VERSION=v2 pnpm run build:extension:beta + else + echo "Building extension using manifest v3" + MANIFEST_VERSION=v3 pnpm run build:extension:beta + fi + + - name: Check bundle size for ${{ matrix.extension_type }} + run: pnpm check-bundle-size + + - name: Use Cache + uses: actions/cache@v4 + with: + path: ./* + key: ${{ github.sha }}-${{ matrix.extension_type }}-${{ matrix.env }} + + - name: Set filename prefix + run: echo "FILENAME_PREFIX=$(echo argent-x-${{ matrix.env }}-${{ github.ref_name }} | tr / -)" >> $GITHUB_ENV + + - name: Install zip + run: apt-get update && apt-get install -y zip + + - name: Create ${{ matrix.extension_type }} zip + run: (cd ./packages/extension/dist && zip -r "../../../${{ env.FILENAME_PREFIX }}-${{ matrix.extension_type }}-beta" .) + + - name: Upload ${{ matrix.extension_type }} extension + uses: actions/upload-artifact@v4 + with: + name: ${{ env.FILENAME_PREFIX }}-${{ matrix.extension_type }}-beta + path: "*-${{ matrix.extension_type }}-beta.zip" + retention-days: 3 + build-all-artifacts: runs-on: ubuntu-latest + container: + image: mcr.microsoft.com/playwright:v1.48.2-jammy strategy: matrix: env: [prod, staging, hydrogen, dev] @@ -23,26 +281,25 @@ jobs: FEATURE_BANXA: "true" FEATURE_LAYERSWAP: "true" FEATURE_ORBITER: "true" - FEATURE_VERIFIED_DAPPS: "true" FEATURE_MULTISIG: "true" - FEATURE_ACTIVITY_V2: "true" + ENABLE_TOKEN_DETAILS: "true" + FEATURE_DEFI_DECOMPOSITION: "true" # API URLs ARGENT_API_BASE_URL: ${{ vars.ARGENT_API_BASE_URL }} ARGENT_X_STATUS_URL: ${{ vars.ARGENT_X_STATUS_URL }} ARGENT_X_NEWS_URL: ${{ vars.ARGENT_X_NEWS_URL }} - ARGENT_TESTNET_RPC_URL: ${{ vars.ARGENT_TESTNET_RPC_URL }} - ARGENT_HEALTHCHECK_BASE_URL: ${{ vars.ARGENT_HEALTHCHECK_BASE_URL }} # API ENVIRONMENT ARGENT_X_ENVIRONMENT: ${{ matrix.env }} # Sentry SENTRY_DSN: ${{ secrets.SENTRY_DSN }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_ORG: ${{ secrets.SENTRY_ORG }} + SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} SENTRY_ENVIRONMENT: "staging" # Misc - SEGMENT_WRITE_KEY: ${{ secrets.SEGMENT_WRITE_KEY }} RAMP_API_KEY: ${{ secrets.RAMP_API_KEY }} SAFE_ENV_VARS: false MULTICALL_MAX_BATCH_SIZE: 20 @@ -53,6 +310,7 @@ jobs: MEDIUM: 60 # 60s SLOW: 60 * 5 # 5m VERY_SLOW: 24 * 60 * 60 # 1d + MIN_LEDGER_APP_VERSION: 2.2.1 #For testing only FEE_OVERHEAD: 2 @@ -65,12 +323,12 @@ jobs: name: Install pnpm id: pnpm-install with: - version: 9 + version: ${{ env.PNPM_VERSION }} run_install: false - uses: actions/setup-node@v4 with: - node-version: "18.x" + node-version: ${{ env.NODE_VERSION }} cache: "pnpm" - name: Get pnpm store directory @@ -87,19 +345,26 @@ jobs: restore-keys: | ${{ runner.os }}-pnpm-store- + - name: Set Sentry Release name (rc) + run: | + PACKAGE_VERSION=$(grep -m1 '"version":' ./packages/extension/package.json | awk -F: '{ print $2 }' | sed 's/[", ]//g') + echo "SENTRY_RELEASE=${PACKAGE_VERSION}-rc__${GITHUB_SHA}" >> $GITHUB_ENV + - name: Setup project run: pnpm run setup - name: Build extension for ${{ matrix.extension_type }} run: | - if [[ "${{ matrix.extension_type }}" == "firefox" ]]; then + if [ "${{ matrix.extension_type }}" = "firefox" ]; then + echo "Building extension using manifest v2" MANIFEST_VERSION=v2 pnpm run build:extension else + echo "Building extension using manifest v3" MANIFEST_VERSION=v3 pnpm run build:extension fi - - name: Check bundlesize for ${{ matrix.extension_type }} - run: pnpm bundlewatch + - name: Check bundle size for ${{ matrix.extension_type }} + run: pnpm check-bundle-size - name: Use Cache uses: actions/cache@v4 @@ -110,6 +375,9 @@ jobs: - name: Set filename prefix run: echo "FILENAME_PREFIX=$(echo argent-x-${{ matrix.env }}-${{ github.ref_name }} | tr / -)" >> $GITHUB_ENV + - name: Install zip + run: apt-get update && apt-get install -y zip + - name: Create ${{ matrix.extension_type }} zip run: (cd ./packages/extension/dist && zip -r "../../../${{ env.FILENAME_PREFIX }}-${{ matrix.extension_type }}" .) @@ -122,17 +390,18 @@ jobs: test-unit: runs-on: ubuntu-latest + container: + image: mcr.microsoft.com/playwright:v1.48.2-jammy needs: [build-all-artifacts] environment: "hydrogen" # test-unit is always run against hydrogen env: ARGENT_API_BASE_URL: ${{ vars.ARGENT_API_BASE_URL }} - ARGENT_TESTNET_RPC_URL: ${{ vars.ARGENT_TESTNET_RPC_URL }} - ARGENT_HEALTHCHECK_BASE_URL: ${{ vars.ARGENT_HEALTHCHECK_BASE_URL }} ARGENT_X_STATUS_URL: ${{ vars.ARGENT_X_STATUS_URL }} ARGENT_X_NEWS_URL: ${{ vars.ARGENT_X_NEWS_URL }} ARGENT_X_ENVIRONMENT: "hydrogen" FEATURE_MULTISIG: "true" + DEVNET_HOST: devnet services: devnet: @@ -149,16 +418,16 @@ jobs: name: Install pnpm id: pnpm-install with: - version: 9 + version: ${{ env.PNPM_VERSION }} run_install: false - uses: actions/setup-node@v4 with: - node-version: "18.x" + node-version: ${{ env.NODE_VERSION }} cache: "pnpm" - name: Restore pnpm cache - uses: actions/cache@v4 + uses: actions/cache/restore@v4 with: path: ~/.pnpm-store key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} @@ -166,7 +435,7 @@ jobs: ${{ runner.os }}-pnpm-store- - name: Restore cached build - uses: actions/cache@v4 + uses: actions/cache/restore@v4 with: path: ./* key: ${{ github.sha }}-chrome-hydrogen # test-unit is always run against chrome-hydrogen build @@ -178,7 +447,7 @@ jobs: run: pnpm run test:ci - name: SonarCloud Scan # TODO replace with master as soon as sonarcloud fixes the issue with action https://community.sonarsource.com/t/sonarsource-sonarcloud-github-action-failing-with-node-js-12-error/89664/2 - uses: SonarSource/sonarcloud-github-action@v2.3.0 + uses: SonarSource/sonarcloud-github-action@v3.1.0 with: projectBaseDir: ./packages/extension env: @@ -187,6 +456,8 @@ jobs: test-e2e: runs-on: ubuntu-latest + container: + image: mcr.microsoft.com/playwright:v1.48.2-jammy needs: [build-all-artifacts] strategy: matrix: @@ -202,9 +473,6 @@ jobs: E2E_SENDER_ADDRESSES: ${{ secrets.E2E_SENDER_ADDRESSES_SEPOLIA }} E2E_SENDER_PRIVATEKEYS: ${{ secrets.E2E_SENDER_PRIVATEKEYS_SEPOLIA }} ARGENT_SEPOLIA_RPC_URL: ${{ secrets.ARGENT_SEPOLIA_RPC_URL }} - ##slack config - SLACK_TOKEN: ${{ secrets.SLACK_TOKEN }} - SLACK_CHANNEL_ID: ${{ secrets.SLACK_CHANNEL_ID }} E2E_TESTNET_SEED1: ${{ secrets.E2E_TESTNET_SEED1 }} E2E_TESTNET_SEED3: ${{ secrets.E2E_TESTNET_SEED3 }} @@ -220,9 +488,16 @@ jobs: E2E_SKIP_TX_TESTS: ${{ secrets.E2E_SKIP_TX_TESTS}} E2E_LOG_INFO: ${{ secrets.E2E_LOG_INFO }} E2E_GUARDIAN_EMAIL: ${{ secrets.E2E_GUARDIAN_EMAIL }} + E2E_MIG_ACCOUNT_ADDRESS: ${{ secrets.E2E_MIG_ACCOUNT_ADDRESS }} + E2E_ACCOUNT_TO_IMPORT_AND_TX: ${{ secrets.E2E_ACCOUNT_TO_IMPORT_AND_TX }} + E2E_ACCOUNTS_TO_IMPORT: ${{ secrets.E2E_ACCOUNTS_TO_IMPORT }} + E2E_QA_UTILS_AUTH_TOKEN: ${{ secrets.E2E_QA_UTILS_AUTH_TOKEN }} + E2E_QA_UTILS_URL: ${{ secrets.E2E_QA_UTILS_URL }} + E2E_MIG_VERSIONS: ${{ secrets.E2E_MIG_VERSIONS }} + E2E_EXTENSION_PASSWORD: ${{ secrets.E2E_EXTENSION_PASSWORD }} # Refresh intervals - REFRESH_INTERVAL_FAST: 1 # 1s - REFRESH_INTERVAL_MEDIUM: 5 # 5s + REFRESH_INTERVAL_FAST: 20 # 1s + REFRESH_INTERVAL_MEDIUM: 20 # 5s REFRESH_INTERVAL_SLOW: 20 # 20s REFRESH_INTERVAL_VERY_SLOW: 60 * 10 # 10m @@ -233,16 +508,16 @@ jobs: name: Install pnpm id: pnpm-install with: - version: 9 + version: ${{ env.PNPM_VERSION }} run_install: false - uses: actions/setup-node@v4 with: - node-version: "18.x" + node-version: ${{ env.NODE_VERSION }} cache: "pnpm" - name: Restore pnpm cache - uses: actions/cache@v4 + uses: actions/cache/restore@v4 with: path: ~/.pnpm-store key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} @@ -250,16 +525,13 @@ jobs: ${{ runner.os }}-pnpm-store- - name: Restore cached build - uses: actions/cache@v4 + uses: actions/cache/restore@v4 with: path: ./* key: ${{ github.sha }}-chrome-${{ env.ARGENT_X_ENVIRONMENT }} # test-e2e is always run against chrome-hydrogen build - - name: Install Playwright Browsers - run: npx playwright install chromium - - name: Run e2e tests - run: xvfb-run --auto-servernum pnpm test:e2e:extension --project=${{ matrix.project }} --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} + run: xvfb-run --auto-servernum pnpm test:e2e --project=${{ matrix.project }} --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} - name: Upload artifacts uses: actions/upload-artifact@v4 @@ -281,6 +553,8 @@ jobs: test-e2e-prod: runs-on: ubuntu-latest + container: + image: mcr.microsoft.com/playwright:v1.48.2-jammy needs: [build-all-artifacts] strategy: matrix: @@ -295,16 +569,14 @@ jobs: E2E_SENDER_PRIVATEKEYS: ${{ secrets.E2E_SENDER_PRIVATEKEYS }} ARGENT_MAINNET_RPC_URL: ${{ secrets.ARGENT_MAINNET_RPC_URL }} ARGENT_SEPOLIA_RPC_URL: ${{ secrets.ARGENT_SEPOLIA_RPC_URL }} - ##slack config - SLACK_TOKEN: ${{ secrets.SLACK_TOKEN }} - SLACK_CHANNEL_ID: ${{ secrets.SLACK_CHANNEL_ID }} E2E_MAINNET_SEED1: ${{ secrets.E2E_MAINNET_SEED1 }} ## BANK ACCOUNT, USED FOR FUND OTHER ACCOUNTS E2E_SENDER_SEED: ${{ secrets.E2E_SENDER_SEED }} + E2E_EXTENSION_PASSWORD: ${{ secrets.E2E_EXTENSION_PASSWORD }} # Refresh intervals - REFRESH_INTERVAL_FAST: 1 # 1s - REFRESH_INTERVAL_MEDIUM: 5 # 5s + REFRESH_INTERVAL_FAST: 20 # 20s + REFRESH_INTERVAL_MEDIUM: 20 # 20s REFRESH_INTERVAL_SLOW: 20 # 20s REFRESH_INTERVAL_VERY_SLOW: 60 * 10 # 10m @@ -316,16 +588,16 @@ jobs: name: Install pnpm id: pnpm-install with: - version: 9 + version: ${{ env.PNPM_VERSION }} run_install: false - uses: actions/setup-node@v4 with: - node-version: "18.x" + node-version: ${{ env.NODE_VERSION }} cache: "pnpm" - name: Restore pnpm cache - uses: actions/cache@v4 + uses: actions/cache/restore@v4 with: path: ~/.pnpm-store key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} @@ -333,16 +605,13 @@ jobs: ${{ runner.os }}-pnpm-store- - name: Restore cached build - uses: actions/cache@v4 + uses: actions/cache/restore@v4 with: path: ./* key: ${{ github.sha }}-chrome-${{ env.ARGENT_X_ENVIRONMENT }} - - name: Install Playwright Browsers - run: npx playwright install chromium - - name: Run e2e prod tests - run: xvfb-run --auto-servernum pnpm test:e2e:extension:prod --project=${{ matrix.project }} --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} + run: xvfb-run --auto-servernum pnpm test:e2e:mainnet --project=${{ matrix.project }} --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} - name: Upload artifacts uses: actions/upload-artifact@v4 @@ -364,6 +633,8 @@ jobs: test-e2e-tx: runs-on: ubuntu-latest + container: + image: mcr.microsoft.com/playwright:v1.48.2-jammy needs: [build-all-artifacts] strategy: matrix: @@ -379,9 +650,6 @@ jobs: E2E_SENDER_ADDRESSES: ${{ secrets.E2E_SENDER_ADDRESSES_SEPOLIA }} E2E_SENDER_PRIVATEKEYS: ${{ secrets.E2E_SENDER_PRIVATEKEYS_SEPOLIA }} ARGENT_SEPOLIA_RPC_URL: ${{ secrets.ARGENT_SEPOLIA_RPC_URL }} - ##slack config - SLACK_TOKEN: ${{ secrets.SLACK_TOKEN }} - SLACK_CHANNEL_ID: ${{ secrets.SLACK_CHANNEL_ID }} E2E_TESTNET_SEED1: ${{ secrets.E2E_TESTNET_SEED1 }} E2E_TESTNET_SEED3: ${{ secrets.E2E_TESTNET_SEED3 }} @@ -397,9 +665,17 @@ jobs: E2E_SKIP_TX_TESTS: ${{ secrets.E2E_SKIP_TX_TESTS}} E2E_LOG_INFO: ${{ secrets.E2E_LOG_INFO }} E2E_GUARDIAN_EMAIL: ${{ secrets.E2E_GUARDIAN_EMAIL }} + E2E_MIG_ACCOUNT_ADDRESS: ${{ secrets.E2E_MIG_ACCOUNT_ADDRESS }} + E2E_ACCOUNT_TO_IMPORT_AND_TX: ${{ secrets.E2E_ACCOUNT_TO_IMPORT_AND_TX }} + E2E_ACCOUNTS_TO_IMPORT: ${{ secrets.E2E_ACCOUNTS_TO_IMPORT }} + E2E_QA_UTILS_AUTH_TOKEN: ${{ secrets.E2E_QA_UTILS_AUTH_TOKEN }} + E2E_QA_UTILS_URL: ${{ secrets.E2E_QA_UTILS_URL }} + INITIAL_BALANCE_MULTIPLIER: ${{ secrets.INITIAL_BALANCE_MULTIPLIER }} + E2E_MIG_VERSIONS: ${{ secrets.E2E_MIG_VERSIONS }} + E2E_EXTENSION_PASSWORD: ${{ secrets.E2E_EXTENSION_PASSWORD }} # Refresh intervals - REFRESH_INTERVAL_FAST: 1 # 1s - REFRESH_INTERVAL_MEDIUM: 5 # 5s + REFRESH_INTERVAL_FAST: 20 # 1s + REFRESH_INTERVAL_MEDIUM: 20 # 5s REFRESH_INTERVAL_SLOW: 20 # 20s REFRESH_INTERVAL_VERY_SLOW: 60 * 10 # 10m steps: @@ -409,32 +685,29 @@ jobs: name: Install pnpm id: pnpm-install with: - version: 9 + version: ${{ env.PNPM_VERSION }} run_install: false - uses: actions/setup-node@v4 with: - node-version: "18.x" + node-version: ${{ env.NODE_VERSION }} cache: "pnpm" - name: Restore pnpm cache - uses: actions/cache@v4 + uses: actions/cache/restore@v4 with: path: ~/.pnpm-store key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} restore-keys: | ${{ runner.os }}-pnpm-store- - name: Restore cached build - uses: actions/cache@v4 + uses: actions/cache/restore@v4 with: path: ./* key: ${{ github.sha }}-chrome-${{ env.ARGENT_X_ENVIRONMENT }} - - name: Install Playwright Browsers - run: npx playwright install chromium - - name: Run e2e tx tests - run: xvfb-run --auto-servernum pnpm test:e2e:extension:tx --project=${{ matrix.project }} --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} + run: xvfb-run --auto-servernum pnpm test:e2e:tx --project=${{ matrix.project }} --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} - name: Upload artifacts uses: actions/upload-artifact@v4 @@ -453,61 +726,31 @@ jobs: name: all-blob-reports-tx-${{ matrix.shardIndex }} path: packages/e2e/blob-report/ retention-days: 5 - test-notify-low-balance: runs-on: ubuntu-latest if: always() needs: [build-all-artifacts, test-e2e, test-e2e-prod] env: - ARGENT_X_ENVIRONMENT: "hydrogen" - ARGENT_API_BASE_URL: ${{ secrets.ARGENT_API_BASE_URL }} - - ## BANK ACCOUNT, USED FOR FUND OTHER ACCOUNTS - E2E_SENDER_ADDRESSES: ${{ secrets.E2E_SENDER_ADDRESSES_SEPOLIA }} - E2E_SENDER_PRIVATEKEYS: ${{ secrets.E2E_SENDER_PRIVATEKEYS_SEPOLIA }} - ARGENT_SEPOLIA_RPC_URL: ${{ secrets.ARGENT_SEPOLIA_RPC_URL }} - - ##slack config - SLACK_TOKEN: ${{ secrets.SLACK_TOKEN }} - SLACK_CHANNEL_ID: ${{ secrets.SLACK_CHANNEL_ID }} + E2E_QA_UTILS_AUTH_TOKEN: ${{ secrets.E2E_QA_UTILS_AUTH_TOKEN }} + E2E_QA_UTILS_URL: ${{ secrets.E2E_QA_UTILS_URL }} steps: - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 - name: Install pnpm - id: pnpm-install - with: - version: 9 - run_install: false - - uses: actions/setup-node@v4 with: - node-version: "18.x" - cache: "pnpm" - - - name: Restore pnpm cache - uses: actions/cache@v4 - with: - path: ~/.pnpm-store - key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-pnpm-store- - - - name: Restore cached build - uses: actions/cache@v4 - with: - path: ./* - key: ${{ github.sha }}-chrome-${{ env.ARGENT_X_ENVIRONMENT }} # test-e2e is always run against chrome-hydrogen build + node-version: ${{ env.NODE_VERSION }} - name: Slack notifications - run: pnpm run test:e2e:slack-notifications + run: node packages/e2e/src/utils/slackNotif.js merge-reports: - needs: [test-e2e, test-e2e-prod] + needs: [test-e2e, test-e2e-prod, test-e2e-tx] if: always() runs-on: ubuntu-latest + container: + image: mcr.microsoft.com/playwright:v1.48.2-jammy steps: - uses: actions/checkout@v4 @@ -515,16 +758,16 @@ jobs: name: Install pnpm id: pnpm-install with: - version: 9 + version: ${{ env.PNPM_VERSION }} run_install: false - uses: actions/setup-node@v4 with: - node-version: "18.x" + node-version: ${{ env.NODE_VERSION }} cache: "pnpm" - name: Restore pnpm cache - uses: actions/cache@v4 + uses: actions/cache/restore@v4 with: path: ~/.pnpm-store key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} @@ -550,97 +793,15 @@ jobs: add_pr_comments: runs-on: ubuntu-latest - if: ${{ github.event_name == 'pull_request' && github.actor != 'dependabot[bot]'}} # Run only for pull requests and if not triggered by dependabot + if: github.event_name == 'pull_request' && github.actor != 'dependabot[bot]' needs: [build-all-artifacts, test-unit, test-e2e, test-e2e-prod] steps: - - uses: actions/checkout@v4 - - - uses: pnpm/action-setup@v4 - name: Install pnpm - id: pnpm-install - with: - version: 9 - run_install: false - - - uses: actions/setup-node@v4 - with: - node-version: "18.x" - cache: "pnpm" - - - name: Restore pnpm cache - uses: actions/cache@v4 - with: - path: ~/.pnpm-store - key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-pnpm-store- - - - name: Set GHA_BRANCH - run: echo "GHA_BRANCH=$(echo $GITHUB_REF | awk -F / '{print $3}')" >> $GITHUB_ENV - - name: Comment PR - continue-on-error: true env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - gh pr comment ${{ env.GHA_BRANCH }} --body "[Builds for local testing](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})" - - create_sentry_release: - runs-on: ubuntu-latest - if: ${{ github.event_name == 'pull_request' && github.actor != 'dependabot[bot]'}} # Run only for pull requests and if not triggered by dependabot - needs: [build-all-artifacts, test-unit, test-e2e, test-e2e-prod] - - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - uses: pnpm/action-setup@v4 - name: Install pnpm - id: pnpm-install - with: - version: 9 - run_install: false - - - uses: actions/setup-node@v4 - with: - node-version: "18.x" - cache: "pnpm" - - - name: Restore cached build - uses: actions/cache@v4 - with: - path: ./* - key: ${{ github.sha }}-chrome-hydrogen - - - name: Get Extension version - id: package-version - run: | - PACKAGE_VERSION=$(cat ./packages/extension/package.json | jq -r '.version') - echo "current-version=${PACKAGE_VERSION}" >> $GITHUB_OUTPUT - - - name: Check sourcemaps + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - ls -l ./packages/extension - if [ ! -d "./packages/extension/sourcemaps" ]; then - echo "No sourcemaps found" - exit 0 - fi - - - name: Create Sentry release - uses: getsentry/action-release@v1 - env: - SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} - SENTRY_ORG: ${{ secrets.SENTRY_ORG }} - SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} - SENTRY_LOG_LEVEL: debug - with: - environment: ${{ env.SENTRY_ENVIRONMENT }} - sourcemaps: "./packages/extension/dist ./packages/extension/sourcemaps" - url_prefix: "~/" - version: ${{ steps.package-version.outputs.current-version }}-rc__${{ github.sha }} - ignore_missing: true + gh pr comment ${{ github.event.pull_request.number }} --repo ${{ github.repository }} --body "[Builds for local testing](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})" slack_notif: runs-on: ubuntu-latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9934683b8..1297eacab 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,10 +6,15 @@ on: - release/* tags: - "**" - +env: + PNPM_VERSION: 9 + NODE_VERSION: 18.x jobs: build: - environment: "prod" + strategy: + matrix: + env: [prod, staging, hydrogen] + environment: ${{ matrix.env }} env: FEATURE_PRIVACY_SETTINGS: "true" FEATURE_EXPERIMENTAL_SETTINGS: "false" @@ -17,31 +22,29 @@ jobs: FEATURE_BANXA: "true" FEATURE_LAYERSWAP: "true" FEATURE_ORBITER: "true" - FEATURE_VERIFIED_DAPPS: "true" - ARGENT_SHIELD_NETWORK_ID: "mainnet-alpha" + ENABLE_TOKEN_DETAILS: "true" + FEATURE_DEFI_DECOMPOSITION: "true" FEATURE_MULTISIG: "true" - FEATURE_ACTIVITY_V2: "true" - SENTRY_ENVIRONMENT: "production" - NPM_ACCESS_TOKEN: ${{ secrets.NPM_ACCESS_TOKEN }} - SEGMENT_WRITE_KEY: ${{ secrets.SEGMENT_WRITE_KEY }} + SENTRY_ENVIRONMENT: ${{ vars.SENTRY_ENVIRONMENT }} SENTRY_DSN: ${{ secrets.SENTRY_DSN }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_ORG: ${{ secrets.SENTRY_ORG }} + SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} RAMP_API_KEY: ${{ secrets.RAMP_API_KEY }} TOPPER_PEM_KEY: ${{ secrets.TOPPER_PEM_KEY }} FILENAME: argent-extension SAFE_ENV_VARS: true ARGENT_API_BASE_URL: ${{ vars.ARGENT_API_BASE_URL }} - ARGENT_TESTNET_RPC_URL: ${{ vars.ARGENT_TESTNET_RPC_URL }} - ARGENT_HEALTHCHECK_BASE_URL: ${{ vars.ARGENT_HEALTHCHECK_BASE_URL }} ARGENT_X_STATUS_URL: ${{ vars.ARGENT_X_STATUS_URL }} ARGENT_X_NEWS_URL: ${{ vars.ARGENT_X_NEWS_URL }} - ARGENT_X_ENVIRONMENT: "prod" + ARGENT_X_ENVIRONMENT: ${{ matrix.env }} MULTICALL_MAX_BATCH_SIZE: 20 # Refresh intervals REFRESH_INTERVAL_FAST: 20 REFRESH_INTERVAL_MEDIUM: 60 REFRESH_INTERVAL_SLOW: 300 REFRESH_INTERVAL_VERY_SLOW: 86400 + MIN_LEDGER_APP_VERSION: 2.2.1 NEW_CAIRO_0_ENABLED: false @@ -59,37 +62,51 @@ jobs: name: Install pnpm id: pnpm-install with: - version: 9 + version: ${{ env.PNPM_VERSION }} run_install: false - uses: actions/setup-node@v4 with: - node-version: "18.x" + node-version: ${{ env.NODE_VERSION }} cache: "pnpm" - - run: pnpm run setup + + - name: Set Sentry Release name + run: | + PACKAGE_VERSION=$(cat ./packages/extension/package.json | jq -r '.version') + echo "SENTRY_RELEASE=${PACKAGE_VERSION}" >> $GITHUB_ENV + + - name: Setup project + run: pnpm run setup - name: Build Chrome version run: pnpm build - - name: Check bundlesize for chrome - run: pnpm bundlewatch + - name: Check bundle size for chrome + run: pnpm check-bundle-size + - name: Set filename prefix + run: | + if [ "${{ matrix.env }}" = "prod" ]; then + echo "FILENAME_PREFIX=$(echo ${{ env.FILENAME }} | tr / -)" >> $GITHUB_ENV + else + echo "FILENAME_PREFIX=$(echo ${{ env.FILENAME }}-${{ matrix.env }} | tr / -)" >> $GITHUB_ENV + fi - name: Create chrome zip - run: (cd ./packages/extension/dist && zip -r "../../../${{ env.FILENAME }}-chrome.zip" .) + run: (cd ./packages/extension/dist && zip -r "../../../${{ env.FILENAME_PREFIX }}-chrome.zip" . -x "*.map") # -x "*.map" exludes source maps needed for Sentry - name: Build Firefox version run: MANIFEST_VERSION=v2 pnpm --filter @argent-x/extension build - name: Create firefox zip - run: (cd ./packages/extension/dist && zip -r "../../../${{ env.FILENAME }}-firefox.zip" .) + run: (cd ./packages/extension/dist && zip -r "../../../${{ env.FILENAME_PREFIX }}-firefox.zip" . -x "*.map") # -x "*.map" exludes source maps needed for Sentry - - name: Check bundlesize for firefox - run: pnpm bundlewatch + - name: Check bundle size for firefox + run: pnpm check-bundle-size - name: Upload artifacts for chrome uses: actions/upload-artifact@v4 with: - name: chrome + name: ${{ matrix.env == 'prod' && 'chrome' || format('{0}-chrome', env.FILENAME_PREFIX) }} path: "*-chrome.zip" retention-days: 14 if-no-files-found: error @@ -97,7 +114,7 @@ jobs: - name: Upload artifacts for firefox uses: actions/upload-artifact@v4 with: - name: firefox + name: ${{ matrix.env == 'prod' && 'firefox' || format('{0}-firefox', env.FILENAME_PREFIX) }} path: "*-firefox.zip" retention-days: 14 if-no-files-found: error @@ -108,41 +125,11 @@ jobs: PACKAGE_VERSION=$(cat ./packages/extension/package.json | jq -r '.version') echo "current-version=${PACKAGE_VERSION}" >> $GITHUB_OUTPUT - - name: Check sourcemaps - run: | - ls -l ./packages/extension - if [ ! -d "./packages/extension/sourcemaps" ]; then - echo "No sourcemaps found" - exit 1 - fi - - - name: Create Sentry release - if: startsWith(github.ref, 'refs/tags/') - uses: getsentry/action-release@v1 - env: - SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} - SENTRY_ORG: ${{ secrets.SENTRY_ORG }} - SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} - SENTRY_LOG_LEVEL: debug - with: - environment: ${{ env.SENTRY_ENVIRONMENT }} - sourcemaps: "./packages/extension/dist ./packages/extension/sourcemaps" - url_prefix: "~/" - version: ${{ steps.package-version.outputs.current-version }} - ignore_missing: true - - - name: Get product version - id: product-version - run: | - PRODUCT_VERSION=$(cat ./packages/extension/dist/manifest.json | jq -r '.version') - echo "current-version=${PRODUCT_VERSION}" >> $GITHUB_OUTPUT - - name: Release if: startsWith(github.ref, 'refs/tags/') uses: softprops/action-gh-release@v2 with: generate_release_notes: true - name: extension@${{ steps.product-version.outputs.current-version }} + name: extension@${{ steps.package-version.outputs.current-version }} files: | - ${{ env.FILENAME }}-chrome.zip - ${{ env.FILENAME }}-firefox.zip + *.zip diff --git a/.github/workflows/upgrade-tests.yml b/.github/workflows/upgrade-tests.yml new file mode 100644 index 000000000..efd7306d0 --- /dev/null +++ b/.github/workflows/upgrade-tests.yml @@ -0,0 +1,228 @@ +name: Run upgrade tests +on: + workflow_dispatch: + +env: + PNPM_VERSION: 9 + NODE_VERSION: 18.x + +jobs: + build-all-artifacts: + runs-on: ubuntu-latest + container: + image: mcr.microsoft.com/playwright:v1.48.2-jammy + strategy: + matrix: + env: [hydrogen] + extension_type: [chrome] + + environment: ${{ matrix.env }} + + env: + # FEATURE flags + FEATURE_PRIVACY_SETTINGS: "true" + FEATURE_EXPERIMENTAL_SETTINGS: "false" + FEATURE_BETA_FEATURES: "false" + FEATURE_BANXA: "true" + FEATURE_LAYERSWAP: "true" + FEATURE_ORBITER: "true" + FEATURE_MULTISIG: "true" + ENABLE_TOKEN_DETAILS: "true" + FEATURE_DEFI_DECOMPOSITION: "true" + + # API URLs + ARGENT_API_BASE_URL: ${{ vars.ARGENT_API_BASE_URL }} + ARGENT_X_STATUS_URL: ${{ vars.ARGENT_X_STATUS_URL }} + ARGENT_X_NEWS_URL: ${{ vars.ARGENT_X_NEWS_URL }} + # API ENVIRONMENT + ARGENT_X_ENVIRONMENT: ${{ matrix.env }} + + # Sentry + SENTRY_DSN: ${{ secrets.SENTRY_DSN }} + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_ORG: ${{ secrets.SENTRY_ORG }} + SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} + SENTRY_ENVIRONMENT: "staging" + + # Misc + RAMP_API_KEY: ${{ secrets.RAMP_API_KEY }} + SAFE_ENV_VARS: false + MULTICALL_MAX_BATCH_SIZE: 20 + NEW_CAIRO_0_ENABLED: false + TOPPER_PEM_KEY: ${{ secrets.TOPPER_PEM_KEY }} + # Refresh intervals + FAST: 20 # 20s + MEDIUM: 60 # 60s + SLOW: 60 * 5 # 5m + VERY_SLOW: 24 * 60 * 60 # 1d + MIN_LEDGER_APP_VERSION: 2.2.0 + + steps: + # Setup Project + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + name: Install pnpm + id: pnpm-install + with: + version: ${{ env.PNPM_VERSION }} + run_install: false + + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: "pnpm" + + - name: Get pnpm store directory + id: pnpm-cache + shell: bash + run: | + echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT + + - uses: actions/cache@v4 + name: Setup pnpm cache + with: + path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} + key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store- + + - name: Set Sentry Release name (rc) + run: | + PACKAGE_VERSION=$(grep -m1 '"version":' ./packages/extension/package.json | awk -F: '{ print $2 }' | sed 's/[", ]//g') + echo "SENTRY_RELEASE=${PACKAGE_VERSION}-rc__${GITHUB_SHA}" >> $GITHUB_ENV + + - name: Setup project + run: pnpm run setup + + - name: Build extension for ${{ matrix.extension_type }} + run: | + if [ "${{ matrix.extension_type }}" = "firefox" ]; then + echo "Building extension using manifest v2" + MANIFEST_VERSION=v2 pnpm run build:extension + else + echo "Building extension using manifest v3" + MANIFEST_VERSION=v3 pnpm run build:extension + fi + + - name: Check bundle size for ${{ matrix.extension_type }} + run: pnpm check-bundle-size + + - name: Use Cache + uses: actions/cache@v4 + with: + path: ./* + key: ${{ github.sha }}-${{ matrix.extension_type }}-${{ matrix.env }} + + - name: Set filename prefix + run: echo "FILENAME_PREFIX=$(echo argent-x-${{ matrix.env }}-${{ github.ref_name }} | tr / -)" >> $GITHUB_ENV + + - name: Install zip + run: apt-get update && apt-get install -y zip + + - name: Create ${{ matrix.extension_type }} zip + run: (cd ./packages/extension/dist && zip -r "../../../${{ env.FILENAME_PREFIX }}-${{ matrix.extension_type }}" .) + + - name: Upload ${{ matrix.extension_type }} extension + uses: actions/upload-artifact@v4 + with: + name: ${{ env.FILENAME_PREFIX }}-${{ matrix.extension_type }} + path: "*-${{ matrix.extension_type }}.zip" + retention-days: 3 + + test-e2e-upgrade: + runs-on: ubuntu-latest + container: + image: mcr.microsoft.com/playwright:v1.48.2-jammy + needs: [build-all-artifacts] + strategy: + matrix: + project: [ArgentX] + shardIndex: [1] + shardTotal: [1] + env: + #default + ARGENT_X_ENVIRONMENT: "hydrogen" + ARGENT_API_BASE_URL: ${{ secrets.ARGENT_API_BASE_URL }} + + ## BANK ACCOUNT, USED FOR FUND OTHER ACCOUNTS + E2E_SENDER_ADDRESSES: ${{ secrets.E2E_SENDER_ADDRESSES_SEPOLIA }} + E2E_SENDER_PRIVATEKEYS: ${{ secrets.E2E_SENDER_PRIVATEKEYS_SEPOLIA }} + ARGENT_SEPOLIA_RPC_URL: ${{ secrets.ARGENT_SEPOLIA_RPC_URL }} + + E2E_TESTNET_SEED1: ${{ secrets.E2E_TESTNET_SEED1 }} + E2E_TESTNET_SEED3: ${{ secrets.E2E_TESTNET_SEED3 }} + E2E_TESTNET_SEED4: ${{ secrets.E2E_TESTNET_SEED4 }} + E2E_ACCOUNT_1_SEED2: ${{ secrets.E2E_ACCOUNT_1_SEED2 }} + ## BANK ACCOUNT, USED FOR FUND OTHER ACCOUNTS + E2E_SENDER_SEED: ${{ secrets.E2E_SENDER_SEED }} + E2E_EXTENSION_PASSWORD: ${{ secrets.E2E_EXTENSION_PASSWORD }} + + E2E_SPOK_CAMPAIGN_URL: ${{ secrets.E2E_SPOK_CAMPAIGN_URL }} + E2E_SPOK_CAMPAIGN_NAME: ${{ secrets.E2E_SPOK_CAMPAIGN_NAME }} + + E2E_USE_STRK_AS_FEE_TOKEN: ${{ secrets.E2E_USE_STRK_AS_FEE_TOKEN }} + E2E_SKIP_TX_TESTS: ${{ secrets.E2E_SKIP_TX_TESTS}} + E2E_LOG_INFO: ${{ secrets.E2E_LOG_INFO }} + E2E_GUARDIAN_EMAIL: ${{ secrets.E2E_GUARDIAN_EMAIL }} + E2E_MIG_ACCOUNT_ADDRESS: ${{ secrets.E2E_MIG_ACCOUNT_ADDRESS }} + E2E_ACCOUNT_TO_IMPORT_AND_TX: ${{ secrets.E2E_ACCOUNT_TO_IMPORT_AND_TX }} + E2E_ACCOUNTS_TO_IMPORT: ${{ secrets.E2E_ACCOUNTS_TO_IMPORT }} + E2E_QA_UTILS_AUTH_TOKEN: ${{ secrets.E2E_QA_UTILS_AUTH_TOKEN }} + E2E_QA_UTILS_URL: ${{ secrets.E2E_QA_UTILS_URL }} + E2E_MIG_VERSIONS: ${{ secrets.E2E_MIG_VERSIONS }} + # Refresh intervals + REFRESH_INTERVAL_FAST: 20 # 1s + REFRESH_INTERVAL_MEDIUM: 20 # 5s + REFRESH_INTERVAL_SLOW: 20 # 20s + REFRESH_INTERVAL_VERY_SLOW: 60 * 10 # 10m + + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + name: Install pnpm + id: pnpm-install + with: + version: ${{ env.PNPM_VERSION }} + run_install: false + + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: "pnpm" + + - name: Restore pnpm cache + uses: actions/cache/restore@v4 + with: + path: ~/.pnpm-store + key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store- + + - name: Restore cached build + uses: actions/cache/restore@v4 + with: + path: ./* + key: ${{ github.sha }}-chrome-${{ env.ARGENT_X_ENVIRONMENT }} # test-e2e is always run against chrome-hydrogen build + + - name: Run e2e tests + run: xvfb-run --auto-servernum pnpm test:e2e:upgrade --project=${{ matrix.project }} --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + if: always() + with: + name: test-artifacts-upgrade-${{ matrix.shardIndex }} + path: | + packages/e2e/artifacts/playwright/ + !packages/e2e/artifacts/playwright/*.webm + retention-days: 5 + + - name: Upload blob report to GitHub Actions Artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: all-blob-reports-upgrade-${{ matrix.shardIndex }} + path: packages/e2e/blob-report/ + retention-days: 5 diff --git a/.nvmrc b/.nvmrc index 8ce703082..7af24b7dd 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -20.16.0 +22.11.0 diff --git a/package.json b/package.json index ef6ce23b4..b70f81a2a 100644 --- a/package.json +++ b/package.json @@ -8,19 +8,16 @@ "@changesets/cli": "^2.26.1", "@lavamoat/allow-scripts": "^3.0.0", "@lavamoat/preinstall-always-fail": "^2.0.0", - "bundlewatch": "^0.4.0", "husky": "^9.0.0", - "import-sort-style-module": "^6.0.0", "lint-staged": "^15.0.0", - "nx": "^19.0.0", "patch-package": "^8.0.0", "prettier": "^3.2.5", "prettier-plugin-import-sort": "^0.0.7", "ts-node": "^10.9.1" }, "resolutions": { - "@babel/preset-react": "7.24.7", - "@babel/plugin-transform-react-jsx": "7.24.7" + "@babel/preset-react": "7.25.9", + "@babel/plugin-transform-react-jsx": "7.25.9" }, "scripts": { "format": "prettier --loglevel warn --write \"**/*.{js,jsx,ts,tsx,css,md,yml,json}\"", @@ -33,20 +30,20 @@ "build:extension": "pnpm run --filter @argent-x/extension build", "build:extension:alpha": "RELEASE_TRACK=alpha pnpm run --filter @argent-x/extension build", "build:extension:beta": "RELEASE_TRACK=beta pnpm run --filter @argent-x/extension build", - "build:sourcemaps": "GEN_SOURCE_MAPS=true pnpm run build", "lint": "pnpm run -r --parallel lint", "test": "pnpm run -r --parallel --stream test", "test:watch": "pnpm run -r --parallel; --stream test:watch", - "test:e2e:extension": "pnpm run --filter @argent-x/e2e test:extension", - "test:e2e:extension:prod": "pnpm run --filter @argent-x/e2e test:extension:prod", - "test:e2e:extension:tx": "pnpm run --filter @argent-x/e2e test:extension:tx", - "test:e2e:slack-notifications": "pnpm run --filter @argent-x/e2e test:slack-notifications", + "test:e2e": "pnpm run --filter @argent-x/e2e test", + "test:e2e:mainnet": "pnpm run --filter @argent-x/e2e test:mainnet", + "test:e2e:tx": "pnpm run --filter @argent-x/e2e test:tx", + "test:e2e:upgrade": "pnpm run --filter @argent-x/e2e test:upgrade", "setup": "pnpm install --frozen-lockfile && pnpm allow-scripts && husky install && patch-package && pnpm run -r --stream setup", "test:ci": "pnpm run --stream --parallel test:ci", "storybook": "cd packages/storybook && pnpm run storybook", "devnet:upgrade-helper": "NODE_NO_WARNINGS=1 ts-node ./scripts/devnet-upgrade-helper.ts", "devnet:setup-contracts": "NODE_NO_WARNINGS=1 ts-node ./scripts/devnet-setup-contracts.ts", - "export:extension": "pnpm run --filter @argent-x/extension export" + "export:extension": "pnpm run --filter @argent-x/extension export", + "check-bundle-size": "pnpm run --filter @argent-x/extension check-bundle-size" }, "importSort": { ".js, .jsx, .ts, .tsx": { @@ -54,15 +51,6 @@ "parser": "typescript" } }, - "bundlewatch": { - "files": [ - { - "path": "packages/extension/dist/**/*.*", - "maxSize": "4mB", - "compression": "none" - } - ] - }, "license": "GPL-3.0-only", "lint-staged": { "*.{js,jsx,ts,tsx,css,md,yml,json}": "prettier --write", @@ -77,7 +65,8 @@ "nx>@swc/core": false, "lerna>@nrwl/devkit>nx": false, "lerna>nx": false, - "lerna>nx>@nrwl/cli>nx": false + "lerna>nx>@nrwl/cli>nx": false, + "ts-node>@swc/core": false } } } diff --git a/packages/e2e/.eslintrc.js b/packages/e2e/.eslintrc.js deleted file mode 100644 index 450bdd061..000000000 --- a/packages/e2e/.eslintrc.js +++ /dev/null @@ -1,45 +0,0 @@ -module.exports = { - settings: { - react: { - version: "detect", - }, - }, - env: { - browser: true, - es2021: true, - node: true, - }, - extends: [ - "eslint:recommended", - "plugin:react/recommended", - "plugin:@typescript-eslint/recommended", - ], - parser: "@typescript-eslint/parser", - parserOptions: { - ecmaFeatures: { - jsx: true, - }, - ecmaVersion: "latest", - sourceType: "module", - }, - plugins: ["react", "react-hooks", "@typescript-eslint"], - rules: { - "no-empty-pattern": "off", - "react/jsx-no-target-blank": "off", - "react/react-in-jsx-scope": "off", - "react-hooks/rules-of-hooks": "error", // Checks rules of Hooks - "react-hooks/exhaustive-deps": "warn", // Checks effect dependencies - "@typescript-eslint/no-explicit-any": "off", - "@typescript-eslint/no-extra-semi": "off", - "@typescript-eslint/no-unused-vars": [ - "warn", - { - vars: "all", - ignoreRestSiblings: true, - argsIgnorePattern: "^_", - }, - ], - "@typescript-eslint/no-non-null-assertion": "off", - curly: "error", - }, -} diff --git a/packages/e2e/.gitignore b/packages/e2e/.gitignore index 5f3360356..679c4924a 100644 --- a/packages/e2e/.gitignore +++ b/packages/e2e/.gitignore @@ -1,3 +1,4 @@ artifacts blob-report -perfTraces.json \ No newline at end of file +perfTraces.json +mig \ No newline at end of file diff --git a/packages/e2e/Dockerfile b/packages/e2e/Dockerfile deleted file mode 100644 index 5498d0ab8..000000000 --- a/packages/e2e/Dockerfile +++ /dev/null @@ -1,6 +0,0 @@ -FROM shardlabs/starknet-devnet:0.6.3 -RUN addgroup -S localuser \ - && adduser -S localuser -G localuser -USER localuser -COPY ./packages/e2e/extension/network-setup /network-setup -ENTRYPOINT [ "starknet-devnet", "--host", "0.0.0.0", "--port", "5050", "--seed", "0", "--lite-mode" ] \ No newline at end of file diff --git a/packages/e2e/extension/src/languages/ILanguage.ts b/packages/e2e/extension/src/languages/ILanguage.ts deleted file mode 100644 index 48ef5716b..000000000 --- a/packages/e2e/extension/src/languages/ILanguage.ts +++ /dev/null @@ -1,149 +0,0 @@ -export interface ILanguage { - common: { - back: string - close: string - confirm: string - done: string - next: string - continue: string - yes: string - no: string - unlock: string - showSettings: string - reset: string - confirmReset: string - save: string - create: string - cancel: string - privacyStatement: string - approve: string - addArgentShield: string - changeAccountType: string - accountUpgraded: string - changedToStandardAccount: string - dismiss: string - reviewSend: string - hide: string - copy: string - beforeYouContinue: string - seedWarning: string - revealSeedPhrase: string - copied: string - confirmRecovery: string - remove: string - upgrade: string - } - account: { - noAccounts: string - createAccount: string - fund: string - fundsFromStarkNet: string - fullAccountAddress: string - send: string - export: string - accountRecovery: string - saveTheRecoveryPhrase: string - pendingTransactions: string - recipientAddress: string - saveAddress: string - confirmTheSeedPhrase: string - showAccountRecovery: string - deployFirst: string - wrongPassword: string - invalidStarkIdError: string - shortAddressError: string - invalidCheckSumError: string - invalidAddress: string - createMultisig: string - activateAccount: string - notEnoughFoundsFee: string - newToken: string - argentShield: { - wrongCode: string - failedCode: string - codeNotRequested: string - emailInUse: string - } - removedFromMultisig: string - copyAddress: string - } - wallet: { - //first screen - banner1: string - desc1: string - createButton: string - restoreButton: string - //second screen - banner2: string - desc2: string - lossOfFunds: string - alphaVersion: string - //third screen - banner3: string - desc3: string - password: string - repeatPassword: string - createWallet: string - //fourth screen - banner4: string - desc4: string - twitter: string - discord: string - finish: string - } - settings: { - account: { - manageOwners: { - manageOwners: string - removeOwner: string - replaceOwner: string - } - setConfirmations: string - viewOnStarkScan: string - viewOnVoyager: string - hideAccount: string - deployAccount: string - connectedDapps: { - connectedDapps: string - connect: string - reject: string - disconnectAll: string - noConnectedDapps: string - } - exportPrivateKey: string - } - preferences: { - preferences: string - hideTokens: string - hiddenAccounts: string - defaultBlockExplorer: string - defaultNFTMarket: string - emailNotifications: string - } - securityPrivacy: { - securityPrivacy: string - autoLockTimer: string - recoveryPhase: string - automaticErrorReporting: string - shareAnonymousData: string - } - addressBook: { - addressBook: string - nameRequired: string - addressRequired: string - removeAddress: string - delete: string - } - developerSettings: { - developerSettings: string - manageNetworks: { - manageNetworks: string - restoreDefaultNetworks: string - } - smartContractDevelopment: string - experimental: string - } - extendedView: string - lockWallet: string - } -} diff --git a/packages/e2e/extension/src/specs/tokens.spec.ts b/packages/e2e/extension/src/specs/tokens.spec.ts deleted file mode 100644 index 997a40dfd..000000000 --- a/packages/e2e/extension/src/specs/tokens.spec.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { expect } from "@playwright/test" - -import test from "../test" -import { transferTokens } from "../../../shared/src/assets" - -test.describe.skip("Tokens", () => { - test("Token should be auto discovered", async ({ extension }) => { - const { accountAddresses } = await extension.setupWallet({ - accountsToSetup: [{ assets: [{ token: "ETH", balance: 0.00000001 }] }], - }) - - //ensure that balance is updated - await Promise.all([ - expect(extension.account.currentBalance("ETH")).toHaveText( - "0.00000001 ETH", - ), - expect(extension.account.currentBalance("WBTC")).toBeHidden(), - ]) - - await transferTokens(0.00000002, accountAddresses[0], "WBTC") - - //ensure that balance is updated - await Promise.all([ - expect(extension.account.currentBalance("ETH")).toHaveText( - "0.00000001 ETH", - ), - expect(extension.account.currentBalance("WBTC")).toHaveText( - "0.00000002 WBTC", - { timeout: 180 * 1000 }, - ), - ]) - }) -}) diff --git a/packages/e2e/extension/src/test.ts b/packages/e2e/extension/src/test.ts deleted file mode 100644 index 003486d03..000000000 --- a/packages/e2e/extension/src/test.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { - artifactsDir, - isCI, - isKeepArtifacts, - keepVideos, - saveHtml, -} from "../../shared/cfg/test" -import path from "path" -import { - ChromiumBrowserContext, - Page, - TestInfo, - chromium, - test as testBase, -} from "@playwright/test" -import { v4 as uuid } from "uuid" -import type { TestExtensions } from "./fixtures" -import ExtensionPage from "./page-objects/ExtensionPage" - -const isExtensionURL = (url: string) => url.startsWith("chrome-extension://") -let browserCtx: ChromiumBrowserContext -const distDir = path.join(__dirname, "../../../extension/dist/") - -const closePages = async (browserContext: ChromiumBrowserContext) => { - const pages = browserContext?.pages() || [] - for (const page of pages) { - const url = page.url() - if (!isExtensionURL(url)) { - await page.close() - } - } -} - -const createBrowserContext = () => { - const userDataDir = `/tmp/test-user-data-${uuid()}` - return chromium.launchPersistentContext(userDataDir, { - headless: false, - args: [ - `${isCI ? "--headless=new" : ""}`, - "--disable-dev-shm-usage", - "--ipc=host", - `--disable-extensions-except=${distDir}`, - `--load-extension=${distDir}`, - "--disable-gpu", - ], - ignoreDefaultArgs: ["--disable-component-extensions-with-background-pages"], - screen: { - width: 300, - height: 600, - }, - recordVideo: { - dir: artifactsDir, - size: { - width: 1080, - height: 720, - }, - }, - }) -} - -const initBrowserWithExtension = async () => { - const browserContext = await createBrowserContext() - await browserContext.addInitScript("window.PLAYWRIGHT = true;") - await browserContext.addInitScript(() => { - window.localStorage.setItem( - "seenNetworkStatusState", - `{"state":{"lastSeen":${Date.now()}},"version":0}`, // tricks the extension into not showing the warning as it thinks it's been seen - ) - }) - - await browserContext.addInitScript(() => { - window.localStorage.setItem("onboardingExperiment", "E1A1") - }) - - let page: Page = browserContext.pages()[0] - - await page.bringToFront() - await page.goto("chrome://extensions") - await page.locator('[id="devMode"]').click() - const extensionId = ( - await page.locator('[id="extension-id"]').first().textContent() - )?.replace("ID: ", "") - - const extensionURL = `chrome-extension://${extensionId}/index.html` - const pages = browserContext.pages() - await page.goto(extensionURL) - await page.waitForTimeout(500) - - const extPage = pages.find( - (x: { url: () => string }) => x.url() === extensionURL, - ) - if (extPage) { - page = extPage - } - if (!page) { - page = pages[0] - } - - await page.emulateMedia({ reducedMotion: "reduce" }) - - /*const ex = await browserContext.addInitScript(() => { - window.localStorage.getItem("onboardingExperiment") - }) - console.log('token previous', ex) - - await browserContext.addInitScript((storage) => { - storage.setItem("onboardingExperiment", "E1A1") - }) - const ex2 = await browserContext.addInitScript(() => { - window.localStorage.getItem("onboardingExperiment") - }) - console.log('token after', ex2)*/ - return { browserContext, extensionURL, page } -} - -function createExtension(label: string) { - return async ({}, use: any, testInfo: TestInfo) => { - const { browserContext, page, extensionURL } = - await initBrowserWithExtension() - - const extension = new ExtensionPage(page, extensionURL) - await closePages(browserContext) - browserCtx = browserContext - await use(extension) - const keepArtifacts = isKeepArtifacts(testInfo) - if (keepArtifacts) { - await saveHtml(testInfo, page, label) - await browserContext.close() - await keepVideos(testInfo, page, label) - } else { - await browserContext.close() - } - } -} - -function getContext() { - return async ({}, use: any, _testInfo: TestInfo) => { - await use(browserCtx) - } -} - -const test = testBase.extend({ - extension: createExtension("extension"), - secondExtension: createExtension("secondExtension"), - thirdExtension: createExtension("thirdExtension"), - browserContext: getContext(), -}) - -export default test diff --git a/packages/e2e/package.json b/packages/e2e/package.json index 7d7c2553b..0b11a7920 100644 --- a/packages/e2e/package.json +++ b/packages/e2e/package.json @@ -11,23 +11,22 @@ "object-hash": "^3.0.0", "react": "^18.0.0", "react-dom": "^18.0.0", - "react-router-dom": "^6.0.1", "swr": "^1.3.0", "zod": "^3.23.8" }, "devDependencies": { - "@playwright/test": "^1.46.0", - "@slack/web-api": "^7.0.0", - "@types/node": "^20.5.7", + "@types/axios": "^0.14.0", + "@playwright/test": "^1.48.1", + "@types/node": "^22.0.0", "@types/uuid": "^10.0.0", "dotenv": "^16.3.1", "starknet": "6.11.0", - "uuid": "^10.0.0" + "uuid": "^11.0.0" }, "scripts": { - "test:extension:prod": "pnpm playwright test --config=./extension --grep '@prodOnly|@all'", - "test:extension:tx": "pnpm playwright test --config=./extension --grep '@tx'", - "test:extension": "pnpm playwright test --config=./extension --grep-invert '@prodOnly|@tx'", - "test:slack-notifications": "pnpm playwright test shared/src/slack.spec.ts" + "test:upgrade": "pnpm playwright test --grep '@upgrade'", + "test:mainnet": "pnpm playwright test --grep '@prodOnly|@all'", + "test:tx": "pnpm playwright test --grep '@tx'", + "test": "pnpm playwright test --grep-invert '@prodOnly|@tx|@upgrade'" } } diff --git a/packages/e2e/extension/playwright.config.ts b/packages/e2e/playwright.config.ts similarity index 53% rename from packages/e2e/extension/playwright.config.ts rename to packages/e2e/playwright.config.ts index c4a917313..2565b9f1b 100644 --- a/packages/e2e/extension/playwright.config.ts +++ b/packages/e2e/playwright.config.ts @@ -1,5 +1,5 @@ import type { PlaywrightTestConfig } from "@playwright/test" -import { isCI, artifactsDir } from "../shared/cfg/test" +import config from "./src/config" const playwrightConfig: PlaywrightTestConfig = { projects: [ @@ -7,30 +7,29 @@ const playwrightConfig: PlaywrightTestConfig = { name: "ArgentX", use: { trace: "retain-on-failure", - viewport: { width: 1080, height: 720 }, actionTimeout: 120 * 1000, // 2 minute permissions: ["clipboard-read", "clipboard-write"], screenshot: "only-on-failure", }, - timeout: 5 * 60e3, // 5 minutes - expect: { timeout: 120 * 1000 }, // 2 minute + timeout: config.isCI ? 5 * 60e3 : 1 * 60e3, + expect: { timeout: 2 * 60e3 }, // 2 minute testDir: "./src/specs", testMatch: /\.spec.ts$/, - retries: isCI ? 1 : 0, - outputDir: artifactsDir, + retries: config.isCI ? 1 : 0, + outputDir: config.artifactsDir, }, ], - workers: isCI ? 2 : 1, + workers: config.isCI ? 2 : 1, fullyParallel: true, reportSlowTests: { threshold: 2 * 60e3, // 2 minutes max: 5, }, - reporter: isCI ? [["github"], ["blob"], ["list"]] : "list", - forbidOnly: isCI, - outputDir: artifactsDir, - preserveOutput: isCI ? "failures-only" : "never", - globalTeardown: "../shared/cfg/global.teardown.ts", + reporter: config.isCI ? [["github"], ["blob"], ["list"]] : "list", + forbidOnly: config.isCI, + outputDir: config.artifactsDir, + preserveOutput: "failures-only", + globalTeardown: "./src/utils/global.teardown.ts", } export default playwrightConfig diff --git a/packages/e2e/shared/cfg/global.teardown.ts b/packages/e2e/shared/cfg/global.teardown.ts deleted file mode 100644 index 2153e09df..000000000 --- a/packages/e2e/shared/cfg/global.teardown.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { artifactsDir } from "./test" -import * as fs from "fs" - -export default function cleanArtifactDir() { - console.time("cleanArtifactDir") - try { - fs.readdirSync(artifactsDir) - .filter((f) => f.endsWith("webm")) - .forEach((fileToDelete) => { - fs.rmSync(`${artifactsDir}/${fileToDelete}`) - }) - } catch (error) { - console.error({ op: "cleanArtifactDir", error }) - } - console.timeEnd("cleanArtifactDir") -} diff --git a/packages/e2e/shared/cfg/test.ts b/packages/e2e/shared/cfg/test.ts deleted file mode 100644 index 39a883e20..000000000 --- a/packages/e2e/shared/cfg/test.ts +++ /dev/null @@ -1,75 +0,0 @@ -import dotenv from "dotenv" -dotenv.config() - -import * as fs from "fs" -import path from "path" - -import { Page, TestInfo } from "@playwright/test" -import { logInfo } from "../src/common" -export const artifactsDir = path.resolve( - __dirname, - "../../artifacts/playwright", -) -export const reportsDir = path.resolve(__dirname, "../../artifacts/reports") -export const isCI = Boolean(process.env.CI) -export const outputFolder = (testInfo: TestInfo) => - testInfo.title.replace(/\s+/g, "_").replace(/\W/g, "") -export const artifactFilename = (testInfo: TestInfo, label: string) => - `${testInfo.retry}-${testInfo.status}-${label}-${testInfo.workerIndex}` -export const isKeepArtifacts = (testInfo: TestInfo) => - testInfo.config.preserveOutput === "always" || - (testInfo.config.preserveOutput === "failures-only" && - testInfo.status === "failed") || - testInfo.status === "timedOut" - -export const artifactSetup = async (testInfo: TestInfo, label: string) => { - await fs.promises - .mkdir(path.resolve(artifactsDir, outputFolder(testInfo)), { - recursive: true, - }) - .catch((error) => { - console.error({ op: "artifactSetup", error }) - }) - return artifactFilename(testInfo, label) -} - -export const saveHtml = async ( - testInfo: TestInfo, - page: Page, - label: string, -) => { - logInfo({ - op: "saveHtml", - label, - }) - const fileName = await artifactSetup(testInfo, label) - const htmlContent = await page.content() - await fs.promises - .writeFile( - path.resolve(artifactsDir, outputFolder(testInfo), `${fileName}.html`), - htmlContent, - ) - .catch((error) => { - console.error({ op: "saveHtml", error }) - }) -} - -export const keepVideos = async ( - testInfo: TestInfo, - page: Page, - label: string, -) => { - logInfo({ - op: "keepVideos", - label, - }) - const fileName = await artifactSetup(testInfo, label) - await page - .video() - ?.saveAs( - path.resolve(artifactsDir, outputFolder(testInfo), `${fileName}.webm`), - ) - .catch((error) => { - console.error({ op: "keepVideos", error }) - }) -} diff --git a/packages/e2e/shared/config.ts b/packages/e2e/shared/config.ts deleted file mode 100644 index 6bf851465..000000000 --- a/packages/e2e/shared/config.ts +++ /dev/null @@ -1,35 +0,0 @@ -import path from "path" -import dotenv from "dotenv" -import fs from "fs" - -const envPath = path.resolve(__dirname, "../.env") -if (fs.existsSync(envPath)) { - dotenv.config({ path: envPath }) -} - -const commonConfig = { - isProdTesting: process.env.ARGENT_X_ENVIRONMENT === "prod" ? true : false, - password: "MyP@ss3!", - //slack - slackToken: process.env.SLACK_TOKEN, - slackChannelId: process.env.SLACK_CHANNEL_ID, - //accounts used for setup - senderAddrs: process.env.E2E_SENDER_ADDRESSES?.split(","), - senderKeys: process.env.E2E_SENDER_PRIVATEKEYS?.split(","), - destinationAddress: process.env.E2E_SENDER_ADDRESSES?.split(",")[0], //used as transfers destination - // urls - rpcUrl: process.env.ARGENT_SEPOLIA_RPC_URL, - beAPIUrl: - process.env.ARGENT_X_ENVIRONMENT === "prod" - ? "" - : process.env.ARGENT_API_BASE_URL, -} - -// check that no value of config is undefined, otherwise throw error -Object.entries(commonConfig).forEach(([key, value]) => { - if (value === undefined) { - throw new Error(`Missing ${key} config variable; check .env file`) - } -}) - -export default commonConfig diff --git a/packages/e2e/shared/src/Utils.ts b/packages/e2e/shared/src/Utils.ts deleted file mode 100644 index e5050f705..000000000 --- a/packages/e2e/shared/src/Utils.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { Page } from "@playwright/test" - -export default class Utils { - page: Page - constructor(page: Page) { - this.page = page - } - - async setClipBoardContent(text: string) { - await this.page.evaluate(`navigator.clipboard.writeText('${text}')`) - } - - async getClipboard() { - return String(await this.page.evaluate(`navigator.clipboard.readText()`)) - } - - async paste() { - const key = process.env.CI ? "Control" : "Meta" - await this.page.keyboard.press(`${key}+KeyV`) - } -} diff --git a/packages/e2e/shared/src/slack.spec.ts b/packages/e2e/shared/src/slack.spec.ts deleted file mode 100644 index eb32907b3..000000000 --- a/packages/e2e/shared/src/slack.spec.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { test } from "@playwright/test" -import { getBalances, notifyLowBalance } from "./assets" -import { isCI } from "../cfg/test" - -test("Slack notifications - Low Balance", async () => { - await notifyLowBalance() -}) -test("Slack notifications - balances", async () => { - test.skip(isCI) - await getBalances() -}) diff --git a/packages/e2e/shared/src/slack.ts b/packages/e2e/shared/src/slack.ts deleted file mode 100644 index 67b343c4c..000000000 --- a/packages/e2e/shared/src/slack.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { WebClient } from "@slack/web-api" -import config from "../config" - -const options = {} -const web = new WebClient(config.slackToken, options) - -export async function sendSlackMessage(message: string) { - const channelId = config.slackChannelId - if (!channelId) { - throw new Error("SLACK_CHANNEL_ID is not defined") - } - await web.chat.postMessage({ - text: message, - channel: channelId, - }) -} diff --git a/packages/e2e/extension/src/config.ts b/packages/e2e/src/config.ts similarity index 52% rename from packages/e2e/extension/src/config.ts rename to packages/e2e/src/config.ts index e6e9d4145..89a9903cc 100644 --- a/packages/e2e/extension/src/config.ts +++ b/packages/e2e/src/config.ts @@ -1,13 +1,33 @@ import path from "path" import dotenv from "dotenv" import fs from "fs" -import commonConfig from "../../shared/config" const envPath = path.resolve(__dirname, "../.env") if (fs.existsSync(envPath)) { dotenv.config({ path: envPath }) } +const commonConfig = { + isProdTesting: process.env.ARGENT_X_ENVIRONMENT === "prod" ? true : false, + password: process.env.E2E_EXTENSION_PASSWORD || "", + //accounts used for setup + senderAddrs: process.env.E2E_SENDER_ADDRESSES?.split(","), + senderKeys: process.env.E2E_SENDER_PRIVATEKEYS?.split(","), + destinationAddress: process.env.E2E_SENDER_ADDRESSES?.split(",")[0], //used as transfers destination + // urls + rpcUrl: process.env.ARGENT_SEPOLIA_RPC_URL, + beAPIUrl: + process.env.ARGENT_X_ENVIRONMENT === "prod" + ? "" + : process.env.ARGENT_API_BASE_URL, + viewportSize: { width: 360, height: 800 }, + artifactsDir: path.resolve(__dirname, "../artifacts/playwright"), + isCI: Boolean(process.env.CI), + migDir: path.join(__dirname, "../../e2e/mig/"), + distDir: path.join(__dirname, "../../extension/dist/"), + migVersionDir: path.join(__dirname, "../../e2e/mig/dist"), +} + const extensionHydrogenConfig = { ...commonConfig, testSeed1: process.env.E2E_TESTNET_SEED1, //wallet with 33 regular deployed accounts and 1 multisig deployed account @@ -20,6 +40,13 @@ const extensionHydrogenConfig = { guardianEmail: process.env.E2E_GUARDIAN_EMAIL, useStrkAsFeeToken: process.env.E2E_USE_STRK_AS_FEE_TOKEN, skipTXTests: process.env.E2E_SKIP_TX_TESTS, + accountsToImport: process.env.E2E_ACCOUNTS_TO_IMPORT, + accountToImportAndTx: process.env.E2E_ACCOUNT_TO_IMPORT_AND_TX?.split(","), + qaUtilsURL: process.env.E2E_QA_UTILS_URL, + qaUtilsAuthToken: process.env.E2E_QA_UTILS_AUTH_TOKEN, + initialBalanceMultiplier: process.env.INITIAL_BALANCE_MULTIPLIER || 1, + migAccountAddress: process.env.E2E_MIG_ACCOUNT_ADDRESS, + migVersions: process.env.E2E_MIG_VERSIONS, } const extensionProdConfig = { @@ -35,6 +62,13 @@ const extensionProdConfig = { guardianEmail: "", useStrkAsFeeToken: "false", skipTXTests: "true", + accountsToImport: "", + accountToImportAndTx: "", + qaUtilsURL: "", + qaUtilsAuthToken: "", + initialBalanceMultiplier: 1, + migAccountAddress: "", + migVersions: "", } const config = commonConfig.isProdTesting @@ -46,4 +80,5 @@ Object.entries(config).forEach(([key, value]) => { throw new Error(`Missing ${key} config variable; check .env file`) } }) + export default config diff --git a/packages/e2e/extension/src/fixtures.ts b/packages/e2e/src/fixtures.ts similarity index 89% rename from packages/e2e/extension/src/fixtures.ts rename to packages/e2e/src/fixtures.ts index 7056a1172..24e387050 100644 --- a/packages/e2e/extension/src/fixtures.ts +++ b/packages/e2e/src/fixtures.ts @@ -7,4 +7,5 @@ export interface TestExtensions { secondExtension: ExtensionPage thirdExtension: ExtensionPage browserContext: ChromiumBrowserContext + upgradeExtension: ExtensionPage } diff --git a/packages/e2e/src/languages/ILanguage.ts b/packages/e2e/src/languages/ILanguage.ts new file mode 100644 index 000000000..c96f1e437 --- /dev/null +++ b/packages/e2e/src/languages/ILanguage.ts @@ -0,0 +1,3 @@ +import texts from "./en" + +export type ILanguage = typeof texts diff --git a/packages/e2e/extension/src/languages/en/index.ts b/packages/e2e/src/languages/en/index.ts similarity index 90% rename from packages/e2e/extension/src/languages/en/index.ts rename to packages/e2e/src/languages/en/index.ts index 0e40cb615..773781248 100644 --- a/packages/e2e/extension/src/languages/en/index.ts +++ b/packages/e2e/src/languages/en/index.ts @@ -70,7 +70,7 @@ const texts = { codeNotRequested: "You have not requested a verification code. Please request a new one.", emailInUse: - "This address is associated with accounts from another seedphrase.Please enter another email address to continue.", + /This address is associated with accounts from another seedphrase[.,]?\s*Please enter another email address to continue[.,]?/, }, removedFromMultisig: "You were removed from this multisig", copyAddress: "Copy address", @@ -90,16 +90,16 @@ const texts = { alphaVersion: "I understand that Starknet may experience performance issues and my transactions may fail for various reasons.", //third screen - banner3: "New wallet", - desc3: "Enter a password to protect your wallet", + banner3: "Create a password", + desc3: "This is used to protect and unlock your wallet", password: "Password", repeatPassword: "Repeat password", createWallet: "Create wallet", //fourth screen - banner4: "Your wallet is ready!", - desc4: "Follow us for product updates or if you have any questions", - twitter: "Follow Argent on X", - discord: "Join the Argent Discord", + banner4: /Your (smart )?account is ready!/, + download: "Download the mobile app", + twitter: "Follow us on X", + dapps: "Explore Starknet apps", finish: "Finish", }, settings: { @@ -114,18 +114,18 @@ const texts = { viewOnVoyager: "View on Voyager", hideAccount: "Hide account", deployAccount: "Deploy account", - connectedDapps: { - connectedDapps: "Connected dapps", + authorisedDapps: { + authorisedDapps: "Authorised dapps", connect: "Connect", reject: "Reject", disconnectAll: "Disconnect all", - noConnectedDapps: "No connected dapps", + noAuthorisedDapps: "No authorised dapps", }, exportPrivateKey: "Export private key", }, preferences: { preferences: "Preferences", - hideTokens: "Hide tokens with no balance", + hideTokens: "Hidden and spam tokens", hiddenAccounts: "Hidden accounts", defaultBlockExplorer: "Default block explorer", defaultNFTMarket: "Default NFT marketplace", @@ -145,8 +145,8 @@ const texts = { removeAddress: "Remove from address book", delete: "Delete", }, - developerSettings: { - developerSettings: "Developer settings", + advancedSettings: { + advancedSettings: "Advanced settings", manageNetworks: { manageNetworks: "Manage networks", restoreDefaultNetworks: "Restore default networks", @@ -157,6 +157,6 @@ const texts = { extendedView: "Extended view", lockWallet: "Lock wallet", }, -} +} as const export default texts diff --git a/packages/e2e/extension/src/languages/index.ts b/packages/e2e/src/languages/index.ts similarity index 55% rename from packages/e2e/extension/src/languages/index.ts rename to packages/e2e/src/languages/index.ts index 583a76ea9..183e1b92b 100644 --- a/packages/e2e/extension/src/languages/index.ts +++ b/packages/e2e/src/languages/index.ts @@ -3,7 +3,6 @@ import path from "node:path" import type { ILanguage } from "./ILanguage" // eslint-disable-next-line @typescript-eslint/no-var-requires -export const lang: ILanguage = require(path.join( - __dirname, - `${process.env.LANGUAGE ?? "en"}`, -)).default +export const lang: ILanguage = require( + path.join(__dirname, `${process.env.LANGUAGE ?? "en"}`), +).default diff --git a/packages/e2e/extension/src/page-objects/Account.ts b/packages/e2e/src/page-objects/Account.ts similarity index 82% rename from packages/e2e/extension/src/page-objects/Account.ts rename to packages/e2e/src/page-objects/Account.ts index 71189fb75..e05faecea 100644 --- a/packages/e2e/extension/src/page-objects/Account.ts +++ b/packages/e2e/src/page-objects/Account.ts @@ -2,13 +2,8 @@ import { Page, expect } from "@playwright/test" import { lang } from "../languages" import Activity from "./Activity" -import { - FeeTokens, - TokenSymbol, - getTokenInfo, -} from "../../../shared/src/assets" -import config from "../../../shared/config" -import { logInfo, sleep } from "../../../shared/src/common" +import { FeeTokens, TokenSymbol, logInfo, sleep } from "../utils" +import config from "../config" export interface IAsset { name: string @@ -17,25 +12,33 @@ export interface IAsset { } export default class Account extends Activity { - constructor(page: Page) { + upgradeTest: boolean + constructor(page: Page, upgradeTest: boolean = false) { super(page) + this.upgradeTest = upgradeTest } accountName1 = "Account 1" accountName2 = "Account 2" + accountName3 = "Account 3" accountNameMulti1 = "Multisig 1" accountNameMulti2 = "Multisig 2" accountNameMulti3 = "Multisig 3" + accountNameMulti4 = "Multisig 4" + accountNameMulti5 = "Multisig 5" + accountNameMulti6 = "Multisig 6" + importedAccountName1 = "Imported Account 1" + importedAccountName2 = "Imported Account 2" get noAccountBanner() { - return this.page.locator(`div h5:has-text("${lang.account.noAccounts}")`) + return this.page.locator(`div h4:has-text("${lang.account.noAccounts}")`) } get createAccount() { - return this.page.locator(`button:text-is("${lang.account.createAccount}")`) + return this.page.locator('[data-testid="create-account-button"]') } get fundMenu() { - return this.page.locator(`button:text-is("${lang.account.fund}")`) + return this.page.getByRole("button", { name: "Fund" }) } get addFundsFromStartNet() { @@ -49,19 +52,27 @@ export default class Account extends Activity { } get accountAddressFromAssetsView() { - return this.page.locator('[data-testid="account-tokens"] button').first() + return this.page.locator('[data-testid="address-copy-button"]').first() } get send() { return this.page.locator(`button:has-text("${lang.account.send}")`) } + get sendToHeader() { + return this.page.getByRole("heading", { name: "Send to" }) + } + get deployAccount() { return this.page.locator( `button :text-is("${lang.settings.account.deployAccount}")`, ) } + get selectTokenButton() { + return this.page.getByTestId("select-token-button") + } + async accountNames() { await expect( this.page.locator('[data-testid="account-name"]').first(), @@ -76,11 +87,7 @@ export default class Account extends Activity { } token(tkn: TokenSymbol) { - const tokenInfo = getTokenInfo(tkn) - if (!tokenInfo) { - throw new Error(`Invalid token: ${tkn}`) - } - return this.page.locator(`button :text-is('${tokenInfo.name}')`) + return this.page.locator(`[data-testid="${tkn}"]`) } get accountListSelector() { @@ -88,13 +95,29 @@ export default class Account extends Activity { } get addANewAccountFromAccountList() { - return this.page.locator('[aria-label="Create new wallet"]') + return this.page.getByRole("button", { name: "Add account" }) } get addStandardAccountFromNewAccountScreen() { return this.page.locator('[aria-label="Standard Account"]') } + get importAccountFromNewAccountScreen() { + return this.page.locator('[aria-label="Import from private key"]') + } + + get importAccountAddressLoc() { + return this.page.locator('[name="address"]') + } + + get importPKLoc() { + return this.page.locator('[name="pk"]') + } + + get importSubmitLoc() { + return this.page.locator('button:text-is("Import")') + } + get addMultisigAccountFromNewAccountScreen() { return this.page.locator('[aria-label="Multisig Account"]') } @@ -132,7 +155,11 @@ export default class Account extends Activity { } account(accountName: string) { - return this.page.locator(`[aria-label^="Select ${accountName}"]`) + return this.page.locator(`button[aria-label^="Select ${accountName}"]`) + } + + accountNameBtnLoc(accountName: string) { + return this.page.locator(`button[aria-label="Select ${accountName}"]`) } get balance() { @@ -179,6 +206,14 @@ export default class Account extends Activity { return this.page.locator('[data-testid="tx-error"]') } + accountGroup( + group: string = "my-accounts" || + "multisig - accounts" || + "imported-accounts", + ) { + return this.page.locator(`[data-testid="${group}"]`) + } + async addAccountMainnet({ firstAccount = true }: { firstAccount?: boolean }) { if (firstAccount) { await this.createAccount.click() @@ -203,12 +238,26 @@ export default class Account extends Activity { await this.createAccount.click() } else { await this.accountListSelector.click() - await this.addANewAccountFromAccountList.click() + await this.page.getByRole("button", { name: "Add account" }).click() } await this.addStandardAccountFromNewAccountScreen.click() await this.continueLocator.click() - - await this.account("").last().click() + await expect(this.account("").last()).toBeVisible() + const accountsName = await this.account("").allInnerTexts() + const accountLoc = this.page.locator( + `[data-testid="Account ${accountsName.length}"]`, + ) + await expect(accountLoc).toBeVisible() + await this.account(`Account ${accountsName.length}`).hover() + await expect( + accountLoc.locator('[data-testid="goto-settings"]'), + ).toBeVisible() + await accountLoc.click() + //todo check why this is needed, click twice + await sleep(1000) + if (await accountLoc.isVisible()) { + await accountLoc.click() + } await expect(this.accountListSelector).toBeVisible() await this.fundMenu.click() await this.addFundsFromStartNet.click() @@ -217,7 +266,7 @@ export default class Account extends Activity { .then((v) => v?.replaceAll(" ", "")) await this.closeLocator.last().click() const accountName = await this.accountListSelector.textContent() - return [accountName, accountAddress] + return { accountName, accountAddress } } async selectAccount(accountName: string) { @@ -230,7 +279,7 @@ export default class Account extends Activity { if (currentAccount != accountName) { await this.selectAccount(accountName) } - await expect(this.accountListSelector).toHaveText(accountName) + await expect(this.accountListSelector).toContainText(accountName) } async assets(accountName: string) { @@ -301,7 +350,7 @@ export default class Account extends Activity { validAddress?: boolean }) { if (fillRecipientAddress === "paste") { - await this.setClipBoardContent(recipientAddress) + await this.setClipboardText(recipientAddress) await this.recipientAddressQuery.focus() await this.paste() } else { @@ -346,8 +395,10 @@ export default class Account extends Activity { feeToken?: FeeTokens }) { await this.ensureSelectedAccount(originAccountName) - await this.token(token).click() + await this.send.click() await this.fillRecipientAddress({ recipientAddress, fillRecipientAddress }) + await this.selectTokenButton.click() + await this.token(token).click() if (amount === "MAX") { await expect(this.balance).toBeVisible() await expect(this.sendMax).toBeVisible() @@ -373,14 +424,15 @@ export default class Account extends Activity { .then(async (_) => { await this.failPredict.click() await this.page.locator('[data-testid="copy-error"]').click() + await this.setClipboard() console.error( "Error message copied to clipboard", await this.getClipboard(), ) throw new Error("Transaction failed") }) - } catch (e) { - null + } catch { + /* empty */ } return { sendAmountTX, sendAmountFE } } @@ -452,7 +504,7 @@ export default class Account extends Activity { } contact(label: string) { - return this.page.locator(`div h6:text-is("${label}")`) + return this.page.locator(`div h5:text-is("${label}")`) } get avnuBanner() { @@ -470,14 +522,18 @@ export default class Account extends Activity { async saveRecoveryPhrase() { const nextModal = await this.nextLocator.isVisible({ timeout: 60 }) if (nextModal) { - await Promise.all([ - expect( - this.page.locator(`h3:has-text("${lang.common.beforeYouContinue}")`), - ).toBeVisible(), - expect( - this.page.locator(`p:has-text("${lang.common.seedWarning}")`), - ).toBeVisible(), - ]) + if (!this.upgradeTest) { + await Promise.all([ + expect( + this.page.locator( + `h2:has-text("${lang.common.beforeYouContinue}")`, + ), + ).toBeVisible(), + expect( + this.page.locator(`p:has-text("${lang.common.seedWarning}")`), + ).toBeVisible(), + ]) + } await this.nextLocator.click() } await this.page @@ -499,6 +555,8 @@ export default class Account extends Activity { this.page.locator(`button:has-text("${lang.common.copied}")`), ).toBeVisible(), ]) + await this.setClipboard() + const seedPhraseCopied = await this.getClipboard() await expect(this.doneLocator).toBeDisabled() await this.page .locator(`p:has-text("${lang.common.confirmRecovery}")`) @@ -506,9 +564,6 @@ export default class Account extends Activity { await expect(this.page.getByTestId("recovery-phrase-checked")).toBeVisible() await expect(this.doneLocator).toBeEnabled() await this.doneLocator.click({ force: true }) - const seedPhraseCopied = await this.page.evaluate( - `navigator.clipboard.readText();`, - ) expect(seed).toBe(seedPhraseCopied) return String(seedPhraseCopied) } @@ -531,6 +586,13 @@ export default class Account extends Activity { } async setupRecovery() { + //ensure modal is loaded + await expect( + this.page.locator('[data-testid="account-tokens"]'), + ).toBeVisible() + await expect( + this.page.locator('[data-testid="address-copy-button"]'), + ).toBeVisible() if (config.isProdTesting) { await this.showAccountRecovery.click() } else { @@ -643,8 +705,9 @@ export default class Account extends Activity { await this.joinExistingMultisig.click() await this.joinWithArgent.click() await this.page.locator('[data-testid="copy-pubkey"]').click() + await this.setClipboard() await this.page.locator('[data-testid="button-done"]').click() - return String(await this.page.evaluate(`navigator.clipboard.readText()`)) + return String(await this.getClipboard()) } async addOwnerToMultisig({ @@ -823,10 +886,26 @@ export default class Account extends Activity { } async gotoSettingsFromAccountList(accountName: string) { - await this.account(accountName).first().hover() + await expect(this.accountNameBtnLoc(accountName)).toBeVisible() + await this.accountNameBtnLoc(accountName).hover() + await expect( + this.accountNameBtnLoc(accountName).locator( + '[data-testid="token-value"]', + ), + ).toBeHidden() + await expect( + this.accountNameBtnLoc(accountName).locator( + '[data-testid="goto-settings"]', + ), + ).toBeVisible() + await expect( + this.accountNameBtnLoc(accountName).locator( + '[data-testid="goto-settings"]', + ), + ).toHaveCount(1) + //todo: remove sleep await sleep(1000) - await this.account(accountName) - .first() + await this.accountNameBtnLoc(accountName) .locator('[data-testid="goto-settings"]') .click() await expect( @@ -835,4 +914,29 @@ export default class Account extends Activity { ), ).toBeVisible() } + + async importAccount({ + address, + privateKey, + validPK = true, + }: { + address: string + privateKey: string + validPK?: boolean + }) { + await this.accountListSelector.click() + await this.addANewAccountFromAccountList.click() + await this.importAccountFromNewAccountScreen.click() + + await this.continueLocator.click() + await this.importAccountAddressLoc.fill(address) + await this.importPKLoc.fill(privateKey) + await this.importSubmitLoc.click() + if (!validPK) { + await Promise.all([ + expect(this.page.getByText("The private key is invalid")).toBeVisible(), + expect(this.page.getByRole("button", { name: "Ok" })).toBeVisible(), + ]) + } + } } diff --git a/packages/e2e/extension/src/page-objects/Activity.ts b/packages/e2e/src/page-objects/Activity.ts similarity index 100% rename from packages/e2e/extension/src/page-objects/Activity.ts rename to packages/e2e/src/page-objects/Activity.ts diff --git a/packages/e2e/extension/src/page-objects/AddressBook.ts b/packages/e2e/src/page-objects/AddressBook.ts similarity index 83% rename from packages/e2e/extension/src/page-objects/AddressBook.ts rename to packages/e2e/src/page-objects/AddressBook.ts index 8b6e46361..e98c399f6 100644 --- a/packages/e2e/extension/src/page-objects/AddressBook.ts +++ b/packages/e2e/src/page-objects/AddressBook.ts @@ -71,4 +71,13 @@ export default class AddressBook extends Navigation { `button:text-is("${lang.settings.addressBook.addressBook}")`, ) } + + async editAddress(name: string) { + await this.page.locator(`[data-testid="${name}"]`).first().click() + await this.page.locator(`[data-testid="${name}"]`).first().click() + await this.page.locator(`[data-testid="${name}"]`).first().hover() + await this.page + .locator(`[data-testid="${name}"] [data-testid^="edit-contact"]`) + .click() + } } diff --git a/packages/e2e/extension/src/page-objects/Dapps.ts b/packages/e2e/src/page-objects/Dapps.ts similarity index 93% rename from packages/e2e/extension/src/page-objects/Dapps.ts rename to packages/e2e/src/page-objects/Dapps.ts index f8fc16da5..a055e239f 100644 --- a/packages/e2e/extension/src/page-objects/Dapps.ts +++ b/packages/e2e/src/page-objects/Dapps.ts @@ -30,7 +30,7 @@ export default class Dapps extends Navigation { get noConnectedDapps() { return this.page.locator( - `text=${lang.settings.account.connectedDapps.noConnectedDapps}`, + `text=${lang.settings.account.authorisedDapps.noAuthorisedDapps}`, ) } @@ -46,19 +46,19 @@ export default class Dapps extends Navigation { disconnectAll() { return this.page.locator( - `p:text-is("${lang.settings.account.connectedDapps.disconnectAll}")`, + `p:text-is("${lang.settings.account.authorisedDapps.disconnectAll}")`, ) } get accept() { return this.page.locator( - `button:text-is("${lang.settings.account.connectedDapps.connect}")`, + `button:text-is("${lang.settings.account.authorisedDapps.connect}")`, ) } get reject() { return this.page.locator( - `button:text-is("${lang.settings.account.connectedDapps.reject}")`, + `button:text-is("${lang.settings.account.authorisedDapps.reject}")`, ) } @@ -68,7 +68,7 @@ export default class Dapps extends Navigation { async ensureKnowDappText() { return Promise.all([ - expect(this.page.locator('h5:text-is("Known Dapp")')).toBeVisible(), + expect(this.page.locator('h4:text-is("Known Dapp")')).toBeVisible(), expect( this.page.locator('p:text-is("This dapp is listed on Dappland")'), ).toBeVisible(), diff --git a/packages/e2e/extension/src/page-objects/DeveloperSettings.ts b/packages/e2e/src/page-objects/DeveloperSettings.ts similarity index 75% rename from packages/e2e/extension/src/page-objects/DeveloperSettings.ts rename to packages/e2e/src/page-objects/DeveloperSettings.ts index 6c9c5ba0d..3ca6937fe 100644 --- a/packages/e2e/extension/src/page-objects/DeveloperSettings.ts +++ b/packages/e2e/src/page-objects/DeveloperSettings.ts @@ -7,7 +7,7 @@ export default class DeveloperSettings { get manageNetworks() { return this.page.locator( - `//a//*[text()="${lang.settings.developerSettings.manageNetworks.manageNetworks}"]`, + `//a//*[text()="${lang.settings.advancedSettings.manageNetworks.manageNetworks}"]`, ) } @@ -19,13 +19,13 @@ export default class DeveloperSettings { get smartCOntractDevelopment() { return this.page.locator( - `//a//*[text()="${lang.settings.developerSettings.smartContractDevelopment}"]`, + `//a//*[text()="${lang.settings.advancedSettings.smartContractDevelopment}"]`, ) } get experimental() { return this.page.locator( - `//a//*[text()="${lang.settings.developerSettings.experimental}"]`, + `//a//*[text()="${lang.settings.advancedSettings.experimental}"]`, ) } @@ -56,12 +56,12 @@ export default class DeveloperSettings { get restoreDefaultNetworks() { return this.page.locator( - `button:has-text("${lang.settings.developerSettings.manageNetworks.restoreDefaultNetworks}")`, + `button:has-text("${lang.settings.advancedSettings.manageNetworks.restoreDefaultNetworks}")`, ) } networkByName(name: string) { - return this.page.locator(`h6:has-text("${name}")`) + return this.page.locator(`h5:has-text("${name}")`) } deleteNetworkByName(name: string) { diff --git a/packages/e2e/extension/src/page-objects/ExtensionPage.ts b/packages/e2e/src/page-objects/ExtensionPage.ts similarity index 76% rename from packages/e2e/extension/src/page-objects/ExtensionPage.ts rename to packages/e2e/src/page-objects/ExtensionPage.ts index 33b1be3af..9caffe559 100644 --- a/packages/e2e/extension/src/page-objects/ExtensionPage.ts +++ b/packages/e2e/src/page-objects/ExtensionPage.ts @@ -1,4 +1,5 @@ import { expect, type Page } from "@playwright/test" +import fs from "fs-extra" import Messages from "./Messages" import Account from "./Account" @@ -10,9 +11,11 @@ import Navigation from "./Navigation" import Network from "./Network" import Settings from "./Settings" import Wallet from "./Wallet" -import config from "../../../shared/config" +import config from "../config" import Nfts from "./Nfts" import Preferences from "./Preferences" +import Swap from "./Swap" +import TokenDetails from "./TokenDetails" import { transferTokens, @@ -21,9 +24,11 @@ import { isScientific, convertScientificToDecimal, FeeTokens, -} from "../../../shared/src/assets" -import Utils from "../../../shared/src/Utils" -import { logInfo } from "../../../shared/src/common" + logInfo, + Clipboard, + unzip, + getVersion, +} from "../utils" export default class ExtensionPage { page: Page @@ -39,15 +44,26 @@ export default class ExtensionPage { dapps: Dapps nfts: Nfts preferences: Preferences - utils: Utils + clipboard: Clipboard + swap: Swap + tokenDetails: TokenDetails + + /** + * The current branch version of the extension. + * @type {string} + */ + currentBranchVersion: string + upgradeTest: boolean = false + constructor( page: Page, private extensionUrl: string, + upgradeTest: boolean = false, ) { this.page = page - this.wallet = new Wallet(page) + this.wallet = new Wallet(page, upgradeTest) this.network = new Network(page) - this.account = new Account(page) + this.account = new Account(page, upgradeTest) this.extensionUrl = extensionUrl this.messages = new Messages(page) this.activity = new Activity(page) @@ -58,11 +74,15 @@ export default class ExtensionPage { this.dapps = new Dapps(page) this.nfts = new Nfts(page) this.preferences = new Preferences(page) - this.utils = new Utils(page) + this.clipboard = new Clipboard(page) + this.swap = new Swap(page) + this.currentBranchVersion = getVersion() + this.tokenDetails = new TokenDetails(page) + this.upgradeTest = upgradeTest } async open() { - await this.page.setViewportSize({ width: 360, height: 800 }) + await this.page.setViewportSize(config.viewportSize) await this.page.goto(this.extensionUrl) } @@ -77,7 +97,7 @@ export default class ExtensionPage { async pasteSeed() { await this.page.locator('[data-testid="seed-input-0"]').focus() - await this.utils.paste() + await this.clipboard.paste() } async recoverWallet(seed: string, password?: string) { @@ -85,7 +105,7 @@ export default class ExtensionPage { await this.wallet.restoreExistingWallet.click() await this.wallet.agreeLoc.click() - await this.utils.setClipBoardContent(seed) + await this.clipboard.setClipboardText(seed) await this.pasteSeed() await this.navigation.continueLocator.click() @@ -93,7 +113,11 @@ export default class ExtensionPage { await this.wallet.repeatPassword.fill(password ?? config.password) await this.navigation.continueLocator.click() - await expect(this.wallet.finish.first()).toBeVisible() + await Promise.race([ + expect(this.wallet.finish).toBeVisible(), + expect(this.page.getByText("Your account is ready!")).toBeVisible(), + expect(this.page.getByText("Your smart account is ready!")).toBeVisible(), + ]) await this.open() await expect(this.network.networkSelector).toBeVisible() @@ -102,7 +126,8 @@ export default class ExtensionPage { async addAccount() { await this.account.addAccount({ firstAccount: false }) await this.account.copyAddress.click() - const accountAddress = await this.utils.getClipboard() + await this.clipboard.setClipboard() + const accountAddress = await this.clipboard.getClipboard() expect(accountAddress).toMatch(/^0x0/) return accountAddress } @@ -120,8 +145,9 @@ export default class ExtensionPage { await this.account.confirmTransaction() await this.navigation.backLocator.click() await this.navigation.closeLocator.click() - await this.navigation.menuActivityLocator.click() - + if (await this.page.getByRole("heading", { name: "Activity" }).isHidden()) { + await this.navigation.menuActivityLocator.click() + } await expect( this.page.getByText(/(Account created and transfer|Account activation)/), ).toBeVisible() @@ -139,7 +165,7 @@ export default class ExtensionPage { validSession = false, }: { accountName: string - email: string + email?: string pin?: string validSession?: boolean }) { @@ -149,7 +175,7 @@ export default class ExtensionPage { await this.settings.smartAccountButton.click() await this.navigation.nextLocator.click() if (!validSession) { - await this.account.email.fill(email) + await this.account.email.fill(email!) await this.navigation.nextLocator.first().click() await this.account.fillPin(pin) } @@ -271,7 +297,7 @@ export default class ExtensionPage { } await this.open() const seed = await this.account.setupRecovery() - await this.network.selectDefaultNetwork() + //await this.network.selectDefaultNetwork() const noAccount = await this.account.noAccountBanner.isVisible({ timeout: 1000, }) @@ -283,7 +309,8 @@ export default class ExtensionPage { await this.account.addAccount({ firstAccount: false }) } await this.account.copyAddress.click() - const accountAddress = await this.utils + await this.clipboard.setClipboard() + const accountAddress = await this.clipboard .getClipboard() .then((adr) => String(adr)) expect(accountAddress).toMatch(/^0x0/) @@ -326,7 +353,7 @@ export default class ExtensionPage { }) await this.navigation.menuActivityActiveLocator .isVisible() - .then(async (visible) => { + .then(async (visible: boolean) => { if (!visible) { await this.navigation.menuActivityLocator.click() } @@ -345,7 +372,7 @@ export default class ExtensionPage { await this.activity.historyButton.click() }) .catch(async () => { - null + return null }) const activityAmount = await activityAmountElement .textContent() @@ -369,21 +396,22 @@ export default class ExtensionPage { async fundMultisigAccount({ accountName, - amount, + balance, }: { accountName: string - amount: number + balance: number }) { await this.account.ensureSelectedAccount(accountName) await this.account.copyAddress.click() - const accountAddress = await this.utils + await this.clipboard.setClipboard() + const accountAddress = await this.clipboard .getClipboard() .then((adr) => String(adr)) await transferTokens( - amount, + balance, accountAddress, // receiver wallet address ) - await this.account.ensureAsset(accountName, "ETH", `${amount} ETH`) + await this.account.ensureAsset(accountName, "ETH", `${balance} ETH`) } async activateMultisig(accountName: string) { @@ -411,4 +439,58 @@ export default class ExtensionPage { await this.navigation.showSettingsLocator.click() await this.settings.account(accountName).click() } + /** + * Version of the current extension, fetch from the html tag data-version attribute + */ + get version() { + return this.page + .locator("html") + .getAttribute("data-version") + .catch(() => "unknown") + } + + async setExtensionVersion(version: string) { + const currentVersionDir = await unzip(version) + await fs.remove(config.migVersionDir) + await fs.copy(currentVersionDir, config.migVersionDir) + // Reload Extension + await this.page.goto("chrome://extensions") + // Find and click the reload button for our extension + const reloadButton = this.page + .locator("extensions-item") + .filter({ hasText: "Argent X" }) + .locator('cr-icon-button[title="Reload"]') + await reloadButton.click() + await this.page.waitForTimeout(1000) + + //open extension + await this.open() + await expect(this.version).resolves.toBe(version) + } + + async restoreExtensionVersion() { + await fs.remove(config.migVersionDir) + await fs.copy(config.distDir, config.migVersionDir) + // Reload Extension + await this.page.goto("chrome://extensions") + // Find and click the reload button for our extension + const reloadButton = this.page + .locator("extensions-item") + .filter({ hasText: "Argent X" }) + .locator('cr-icon-button[title="Reload"]') + await reloadButton.click() + await this.page.waitForTimeout(1000) + + //open extension + await this.open() + } + + async upgradeExtension(newVersion: string) { + await this.restoreExtensionVersion() + //unlock extension + await this.account.password.fill(config.password) + await this.navigation.unlockLocator.click() + //check extension version + await expect(this.version).resolves.toBe(newVersion) + } } diff --git a/packages/e2e/extension/src/page-objects/Messages.ts b/packages/e2e/src/page-objects/Messages.ts similarity index 100% rename from packages/e2e/extension/src/page-objects/Messages.ts rename to packages/e2e/src/page-objects/Messages.ts diff --git a/packages/e2e/extension/src/page-objects/Navigation.ts b/packages/e2e/src/page-objects/Navigation.ts similarity index 97% rename from packages/e2e/extension/src/page-objects/Navigation.ts rename to packages/e2e/src/page-objects/Navigation.ts index 5e934815c..3be34e157 100644 --- a/packages/e2e/extension/src/page-objects/Navigation.ts +++ b/packages/e2e/src/page-objects/Navigation.ts @@ -1,9 +1,9 @@ import type { Page } from "@playwright/test" import { lang } from "../languages" -import Utils from "../../../shared/src/Utils" +import Clipboard from "../utils/Clipboard" -export default class Navigation extends Utils { +export default class Navigation extends Clipboard { constructor(page: Page) { super(page) } diff --git a/packages/e2e/extension/src/page-objects/Network.ts b/packages/e2e/src/page-objects/Network.ts similarity index 66% rename from packages/e2e/extension/src/page-objects/Network.ts rename to packages/e2e/src/page-objects/Network.ts index 428a00c4f..a5ba2ceb8 100644 --- a/packages/e2e/extension/src/page-objects/Network.ts +++ b/packages/e2e/src/page-objects/Network.ts @@ -1,4 +1,5 @@ import { Page, expect } from "@playwright/test" +import Navigation from "./Navigation" type NetworkName = "Devnet" | "Sepolia" | "Mainnet" | "My Network" @@ -26,10 +27,14 @@ export function getDefaultNetwork() { return defaultNetworkId } -export default class Network { - constructor(private page: Page) {} +export default class Network extends Navigation { + // Change 'private' to 'protected' or 'public' to match the base class + constructor(page: Page) { + super(page) + } + get networkSelector() { - return this.page.locator('button[aria-label="Selected network"]') + return this.page.getByLabel("Show account list") } networkOption(name: string) { @@ -38,21 +43,32 @@ export default class Network { async selectNetwork(networkName: NetworkName) { await this.networkSelector.click() + await this.page.locator('[data-testid="network-switcher-button"]').click() await this.networkOption(networkName).click() } async selectDefaultNetwork() { const networkName = this.getDefaultNetworkName() await this.networkSelector.click() + await this.page.locator('[data-testid="network-switcher-button"]').click() await this.networkOption(networkName).click() + const accounts = await this.page + .locator('[aria-label^="Select A"]') + .allInnerTexts() + if (accounts.length > 0) { + await this.page.locator('[aria-label^="Select A"]').first().click() + } else { + await this.closeButtonLocator.click() + } } async ensureAvailableNetworks(networks: string[]) { await this.networkSelector.click() + await this.page.locator('[data-testid="network-switcher-button"]').click() const availableNetworks = await this.page .locator('[role="menu"] button') .allInnerTexts() - return networks.map((net) => expect(availableNetworks).toContain(net)) + return expect(availableNetworks).toEqual(networks) } getDefaultNetworkName() { @@ -70,6 +86,6 @@ export default class Network { } ensureSelectedNetwork(networkName: NetworkName) { - return expect(this.networkSelector).toHaveText(networkName) + return expect(this.networkSelector).toContainText(networkName) } } diff --git a/packages/e2e/extension/src/page-objects/Nfts.ts b/packages/e2e/src/page-objects/Nfts.ts similarity index 88% rename from packages/e2e/extension/src/page-objects/Nfts.ts rename to packages/e2e/src/page-objects/Nfts.ts index 4db6bbff4..15a56ccdc 100644 --- a/packages/e2e/extension/src/page-objects/Nfts.ts +++ b/packages/e2e/src/page-objects/Nfts.ts @@ -8,7 +8,7 @@ export default class Nfts extends Navigation { } collection(name: string) { - return this.page.locator(`h6:text-is("${name}")`) + return this.page.locator(`h5:text-is("${name}")`) } ntf(name: string) { diff --git a/packages/e2e/extension/src/page-objects/Preferences.ts b/packages/e2e/src/page-objects/Preferences.ts similarity index 96% rename from packages/e2e/extension/src/page-objects/Preferences.ts rename to packages/e2e/src/page-objects/Preferences.ts index 7c15b11da..96330d2d9 100644 --- a/packages/e2e/extension/src/page-objects/Preferences.ts +++ b/packages/e2e/src/page-objects/Preferences.ts @@ -8,7 +8,7 @@ export default class Preferences extends Navigation { super(page) } - get hideTokens() { + get hiddenAndSpamTokens() { return this.page.locator( `//p[contains(text(),'${lang.settings.preferences.hideTokens}')]`, ) diff --git a/packages/e2e/extension/src/page-objects/Settings.ts b/packages/e2e/src/page-objects/Settings.ts similarity index 65% rename from packages/e2e/extension/src/page-objects/Settings.ts rename to packages/e2e/src/page-objects/Settings.ts index a88957b87..eaa9ef524 100644 --- a/packages/e2e/extension/src/page-objects/Settings.ts +++ b/packages/e2e/src/page-objects/Settings.ts @@ -1,9 +1,13 @@ -import type { Page } from "@playwright/test" +import { expect, type Page } from "@playwright/test" import { lang } from "../languages" +import { sleep } from "../utils" +import Navigation from "./Navigation" -export default class Settings { - constructor(private page: Page) {} +export default class Settings extends Navigation { + constructor(page: Page) { + super(page) + } get extendedView() { return this.page.locator(`[aria-label="${lang.settings.extendedView}"]`) @@ -15,15 +19,15 @@ export default class Settings { ) } - get connectedDapps() { + get authorizedDapps() { return this.page.locator( - `//a//*[text()="${lang.settings.account.connectedDapps.connectedDapps}"]`, + `//a//*[text()="${lang.settings.account.authorisedDapps.authorisedDapps}"]`, ) } - get developerSettings() { + get advancedSettings() { return this.page.locator( - `//a//*[text()="${lang.settings.developerSettings.developerSettings}"]`, + `//a//*[text()="${lang.settings.advancedSettings.advancedSettings}"]`, ) } @@ -39,9 +43,7 @@ export default class Settings { } get exportPrivateKey() { - return this.page.locator( - `//button//*[text()="${lang.settings.account.exportPrivateKey}"]`, - ) + return this.page.getByRole("button", { name: "Export private key" }) } get deployAccount() { @@ -51,9 +53,7 @@ export default class Settings { } get hideAccount() { - return this.page.locator( - `//button//*[text()="${lang.settings.account.hideAccount}"]`, - ) + return this.page.getByRole("button", { name: "Hide account" }) } account(accountName: string) { @@ -120,4 +120,23 @@ export default class Settings { name: lang.settings.account.viewOnVoyager, }) } + + get pinLocator() { + return this.page.locator('[aria-label="Please enter your pin code"]') + } + + async signIn(email: string, pin: string = "111111") { + await this.page.getByRole("button", { name: "Sign in to Argent" }).click() + await this.page.getByTestId("email-input").fill(email) + await this.nextLocator.click() + //avoid BE error PIN not requested + await sleep(2000) + await expect(this.pinLocator).toHaveCount(6) + await this.pinLocator.first().click() + await this.pinLocator.first().fill(pin) + await expect( + this.page.getByRole("button", { name: "Logout" }), + ).toBeVisible() + await this.closeLocator.click() + } } diff --git a/packages/e2e/src/page-objects/Swap.ts b/packages/e2e/src/page-objects/Swap.ts new file mode 100644 index 000000000..7065f289d --- /dev/null +++ b/packages/e2e/src/page-objects/Swap.ts @@ -0,0 +1,95 @@ +import { Page, expect } from "@playwright/test" + +import Navigation from "./Navigation" +import { TokenSymbol } from "../utils" + +export default class Swap extends Navigation { + constructor(page: Page) { + super(page) + } + + get swapHeader() { + return this.page.getByRole("heading", { name: "Swap" }) + } + + get valueLoc() { + return this.page.locator('[data-testid="swap-input-pay-panel"]') + } + + get switchInOutLoc() { + return this.page.locator('[aria-label="Switch input and output"]') + } + + get maxLoc() { + return this.page.locator('label:has-text("Max")') + } + + get payTokenLoc() { + return this.page.locator('[data-testid="swap-token-button"]').nth(0) + } + + get receiveTokenLoc() { + return this.page.locator('[data-testid="swap-token-button"]').nth(1) + } + + get reviewSwapLoc() { + return this.page.locator('[data-testid="review-swap-button"]') + } + + get deployFeeLoc() { + return this.page.locator('[data-testid="deploy-fee"]') + } + + get useMaxLoc() { + return this.page.locator('[data-testid="use-max-button"]') + } + + async setPayToken(token: string) { + await this.payTokenLoc.click() + await this.page.locator(`p:text-is("${token}")`).click() + } + + async setReceiveToken(token: string) { + await this.receiveTokenLoc.click() + await this.page.locator(`p:text-is("${token}")`).click() + } + + async swapTokens({ + payToken, + receiveToken, + amount, + alreadyDeployed = true, + }: { + payToken: TokenSymbol + receiveToken: TokenSymbol + amount: number | "MAX" + alreadyDeployed: boolean + }) { + await this.setPayToken(payToken) + await this.setReceiveToken(receiveToken) + if (amount === "MAX") { + await this.maxLoc.click() + await this.useMaxLoc.click() + } else { + await this.valueLoc.fill(amount.toString()) + } + await this.reviewSwapLoc.click() + //raise an error if Transaction fail predict, to avoid waiting test timeout + const failPredict = this.page.getByText("Transaction fail") + await expect(failPredict) + .toBeVisible({ timeout: 1000 * 5 }) + .then(async (_) => { + throw new Error("Transaction failure predicted") + }) + .catch((_) => null) + if (!alreadyDeployed) { + await expect(this.deployFeeLoc).toBeVisible() + } + const sendAmountFEText = await this.page + .locator("[data-fe-value]") + .nth(1) + .getAttribute("data-fe-value") + await this.confirmLocator.click() + return sendAmountFEText + } +} diff --git a/packages/e2e/src/page-objects/TokenDetails.ts b/packages/e2e/src/page-objects/TokenDetails.ts new file mode 100644 index 000000000..8b96aff7a --- /dev/null +++ b/packages/e2e/src/page-objects/TokenDetails.ts @@ -0,0 +1,94 @@ +import { expect, Page } from "@playwright/test" + +import Navigation from "./Navigation" + +export default class TokenDetails extends Navigation { + constructor(page: Page) { + super(page) + } + + openTokenDetails(token: string) { + return this.page.getByTestId(`${token}-balance`) + } + + get swapButtonLoc() { + return this.page.locator('button[aria-label="Swap"]') + } + + get buyButtonLoc() { + return this.page.locator('button[aria-label="Buy"]') + } + + get sendButtonLoc() { + return this.page.locator('button[aria-label="Send"]') + } + + graphTimeFrameLoc(frame: "1D" | "1W" | "1M" | "1Y" | "All") { + return this.page.locator(`button:text-is('${frame}')`) + } + + get activityButtonLoc() { + return this.page.locator(`button:text-is('Activity')`) + } + + get aboutButtonLoc() { + return this.page.locator(`button:text-is('About')`) + } + + get menuButtonLoc() { + return this.page.locator('[id^="menu-button"]') + } + + get menuCopyTokenAddressLoc() { + return this.page.getByText("Copy token address") + } + + get menuViewOnVoyagerLoc() { + return this.page.getByRole("menuitem", { name: "View on Voyager" }) + } + + get newTokenButtonLoc() { + return this.page.getByText("New token") + } + + get addTokenButtonLoc() { + return this.page.getByRole("button", { name: "Add token" }) + } + + fillTokenAddress(tokenAddress: string) { + return this.page.locator("[name='address']").fill(tokenAddress) + } + + async addNewToken(tokenAddress: string, tokenSymbol: string) { + await this.newTokenButtonLoc.click() + await this.fillTokenAddress(tokenAddress) + await expect(this.page.locator('[name="symbol"]')).toHaveValue(tokenSymbol) + await Promise.race([ + this.addTokenButtonLoc.click(), + this.addThisToken.click(), + ]) + } + + token(tokenName: string) { + return this.page.locator(`h5:text-is('${tokenName}')`) + } + + showToken(tokenSymbol: string) { + return this.page.locator(`[data-testid="show-token-button-${tokenSymbol}"]`) + } + + hideToken(tokenSymbol: string) { + return this.page.locator(`[data-testid="hide-token-button-${tokenSymbol}"]`) + } + + get spamTokensList() { + return this.page.getByRole("button", { name: "Spam" }) + } + + get tokensList() { + return this.page.getByRole("button", { name: "Tokens" }) + } + get addThisToken() { + return this.page.getByRole("button", { name: "Add this token" }) + } +} diff --git a/packages/e2e/extension/src/page-objects/Wallet.ts b/packages/e2e/src/page-objects/Wallet.ts similarity index 72% rename from packages/e2e/extension/src/page-objects/Wallet.ts rename to packages/e2e/src/page-objects/Wallet.ts index 6c8bbb275..b910a4c60 100644 --- a/packages/e2e/extension/src/page-objects/Wallet.ts +++ b/packages/e2e/src/page-objects/Wallet.ts @@ -1,16 +1,18 @@ import { Page, expect } from "@playwright/test" -import config from "../../../shared/config" +import config from "../config" import { lang } from "../languages" import Navigation from "./Navigation" -import { sleep } from "../../../shared/src/common" +import { sleep } from "../utils" export default class Wallet extends Navigation { - constructor(page: Page) { + upgradeTest: boolean + constructor(page: Page, upgradeTest: boolean = false) { super(page) + this.upgradeTest = upgradeTest } get banner() { - return this.page.locator(`div h2:text-is("${lang.wallet.banner1}")`) + return this.page.locator(`div h1:text-is("${lang.wallet.banner1}")`) } get description() { return this.page.locator(`div p:text-is("${lang.wallet.desc1}")`) @@ -24,7 +26,7 @@ export default class Wallet extends Navigation { //second screen get banner2() { - return this.page.locator(`div h2:text-is("${lang.wallet.banner2}")`) + return this.page.locator(`div h1:text-is("${lang.wallet.banner2}")`) } get description2() { return this.page.locator(`div p:text-is("${lang.wallet.desc2}")`) @@ -47,7 +49,7 @@ export default class Wallet extends Navigation { //third screen get banner3() { - return this.page.locator(`div h2:text-is("${lang.wallet.banner3}")`) + return this.page.locator(`div h1:text-is("${lang.wallet.banner3}")`) } get description3() { return this.page.locator(`div p:text-is("${lang.wallet.desc3}")`) @@ -68,17 +70,23 @@ export default class Wallet extends Navigation { //fourth screen get banner4() { - return this.page.locator(`div h2:text-is("${lang.wallet.banner4}")`) + return this.page.locator("div h1", { + hasText: lang.wallet.banner4, + }) } - get description4() { - return this.page.locator(`div p:text-is("${lang.wallet.desc4}")`) + + get download() { + return this.page.locator(`a:has-text("${lang.wallet.download}")`) } + get twitter() { - return this.page.locator(`a:text-is("${lang.wallet.twitter}")`) + return this.page.locator(`a:has-text("${lang.wallet.twitter}")`) } - get discord() { - return this.page.locator(`a:text-is("${lang.wallet.discord}")`) + + get dapps() { + return this.page.locator(`a:has-text("${lang.wallet.dapps}")`) } + get finish() { return this.page.locator(`button:text-is("${lang.wallet.finish}")`) } @@ -115,17 +123,21 @@ export default class Wallet extends Navigation { pin: string = "111111", success: boolean = true, ) { - await Promise.all([ - expect(this.banner).toBeVisible(), - expect(this.description).toBeVisible(), - expect(this.restoreExistingWallet).toBeVisible(), - ]) + if (!this.upgradeTest) { + await Promise.all([ + expect(this.banner).toBeVisible(), + expect(this.description).toBeVisible(), + expect(this.restoreExistingWallet).toBeVisible(), + ]) + } await this.createNewWallet.click() await this.agreeLoc.click() - await Promise.all([ - expect(this.banner3).toBeVisible(), - expect(this.description3).toBeVisible(), - ]) + if (!this.upgradeTest) { + await Promise.all([ + expect(this.banner3).toBeVisible(), + expect(this.description3).toBeVisible(), + ]) + } await this.password.fill(config.password) await this.repeatPassword.fill(config.password) await this.continueLocator.click() @@ -144,14 +156,17 @@ export default class Wallet extends Navigation { ).toBeVisible() } } - if (success) { + if (success && !this.upgradeTest) { await Promise.all([ expect(this.banner4).toBeVisible(), - expect(this.description4).toBeVisible(), + expect(this.download).toBeVisible(), expect(this.twitter).toBeVisible(), - expect(this.discord).toBeVisible(), - expect(this.finish).toBeEnabled(), + expect(this.dapps).toBeVisible(), ]) + } else if (success && this.upgradeTest) { + await expect( + this.page.getByRole("heading", { name: "Your wallet is ready!" }), + ).toBeVisible() } } } diff --git a/packages/e2e/extension/src/specs/accountSettings.spec.ts b/packages/e2e/src/specs/accountSettings.spec.ts similarity index 93% rename from packages/e2e/extension/src/specs/accountSettings.spec.ts rename to packages/e2e/src/specs/accountSettings.spec.ts index 0ccdf19b9..3bd8e0fe1 100644 --- a/packages/e2e/extension/src/specs/accountSettings.spec.ts +++ b/packages/e2e/src/specs/accountSettings.spec.ts @@ -4,6 +4,8 @@ import test from "../test" import { lang } from "../languages" import config from "../config" +const ethInitialBalance = 0.002 * Number(config.initialBalanceMultiplier) + test.describe("Account settings", () => { test( "User should be able to edit account name", @@ -42,10 +44,9 @@ test.describe("Account settings", () => { await extension.account.password.fill(config.password) await extension.account.unlockLocator.click() await extension.settings.copy.click() + await extension.clipboard.setClipboard() //ensure that copy is working - const clipboardPrivateKey = await extension.page.evaluate( - "navigator.clipboard.readText()", - ) + const clipboardPrivateKey = await extension.clipboard.getClipboard() expect(clipboardPrivateKey).toEqual( await extension.settings.privateKey.textContent(), ) @@ -134,7 +135,9 @@ test.describe("Account settings", () => { { tag: "@tx" }, async ({ extension, secondExtension }) => { const { seed } = await extension.setupWallet({ - accountsToSetup: [{ assets: [{ token: "ETH", balance: 0.01 }] }], + accountsToSetup: [ + { assets: [{ token: "ETH", balance: ethInitialBalance }] }, + ], }) await secondExtension.open() await Promise.all([ @@ -148,8 +151,8 @@ test.describe("Account settings", () => { ]) //ensure that balance is updated - await expect(extension.account.currentBalance("ETH")).toContainText( - "0.00", + await expect(extension.account.currentBalance("ETH")).not.toContainText( + ethInitialBalance.toString(), ) await extension.navigation.showSettingsLocator.click() await extension.settings.account(extension.account.accountName1).click() @@ -185,10 +188,9 @@ test.describe("Account settings", () => { await extension.account.fundMenu.click() await extension.account.addFundsFromStartNet.click() await extension.account.copyAddressFromFundMenu.click() + await extension.clipboard.setClipboard() await expect(extension.page.locator("text=Copied")).toBeVisible() - const addressFromClipboard = await extension.page.evaluate( - "navigator.clipboard.readText()", - ) + const addressFromClipboard = await extension.clipboard.getClipboard() const addressFromModal = await extension.page .locator('[aria-label="Full account address"]') .textContent() @@ -204,13 +206,12 @@ test.describe("Account settings", () => { await expect(extension.network.networkSelector).toBeVisible() await extension.network.selectNetwork("Sepolia") - await extension.account.accountListSelector.click() await extension.account.gotoSettingsFromAccountList( extension.account.accountName2, ) await extension.navigation.backLocator.click() await extension.account.gotoSettingsFromAccountList( - extension.account.accountName1, + extension.account.accountName3, ) }) @@ -222,7 +223,6 @@ test.describe("Account settings", () => { await extension.recoverWallet(config.testSeed1!) await expect(extension.network.networkSelector).toBeVisible() await extension.network.selectNetwork("Mainnet") - await extension.account.accountListSelector.click() await extension.account.gotoSettingsFromAccountList( extension.account.accountName2, ) diff --git a/packages/e2e/extension/src/specs/addressBook.spec.ts b/packages/e2e/src/specs/addressBook.spec.ts similarity index 80% rename from packages/e2e/extension/src/specs/addressBook.spec.ts rename to packages/e2e/src/specs/addressBook.spec.ts index 4feb6ebf8..e6fbe1922 100644 --- a/packages/e2e/extension/src/specs/addressBook.spec.ts +++ b/packages/e2e/src/specs/addressBook.spec.ts @@ -3,15 +3,19 @@ import { expect } from "@playwright/test" import config from "../config" import test from "../test" +const ethInitialBalance = 0.002 * Number(config.initialBalanceMultiplier) + test.describe("Address Book", { tag: "@tx" }, () => { test.skip(config.skipTXTests === "true") test("Add, update, use and delete address", async ({ extension }) => { await extension.setupWallet({ - accountsToSetup: [{ assets: [{ token: "ETH", balance: 0.002 }] }], + accountsToSetup: [ + { assets: [{ token: "ETH", balance: ethInitialBalance }] }, + ], }) - await extension.navigation.showSettingsLocator.click() - await extension.settings.addressBook.click() + await extension.account.send.click() + await extension.addressBook.addressBook.click() //create await extension.addressBook.add.click() await extension.addressBook.saveLocator.click() @@ -25,19 +29,16 @@ test.describe("Address Book", { tag: "@tx" }, () => { await expect(extension.addressBook.nameRequired).not.toBeVisible() await expect(extension.addressBook.addressRequired).not.toBeVisible() await extension.addressBook.network.click() - await extension.addressBook.networkOption("Sepolia").click() await extension.addressBook.saveLocator.click() - // update - await extension.addressBook.addressByName("My first address").click() + await extension.addressBook.editAddress("My first address") await extension.addressBook.name.fill("New name") await extension.addressBook.saveLocator.click() await expect(extension.addressBook.addressByName("New name")).toBeVisible() await extension.navigation.backLocator.click() - await extension.navigation.closeLocator.click() //transfer to address - await extension.account.token("ETH").click() + await extension.account.send.click() await extension.addressBook.addressBook.click() await extension.addressBook.addressByName("New name").click() await extension.account.sendMax.click() @@ -52,9 +53,10 @@ test.describe("Address Book", { tag: "@tx" }, () => { //delete address await extension.navigation.menuTokensLocator.click() - await extension.navigation.showSettingsLocator.click() - await extension.settings.addressBook.click() - await extension.addressBook.addressByName("New name").click() + await extension.account.send.click() + await extension.addressBook.addressBook.click() + + await extension.addressBook.editAddress("New name") await extension.addressBook.deleteAddress.click() await extension.addressBook.delete.click() await expect( @@ -64,13 +66,15 @@ test.describe("Address Book", { tag: "@tx" }, () => { test("Add address after typing", async ({ extension }) => { await extension.setupWallet({ - accountsToSetup: [{ assets: [{ token: "ETH", balance: 0.002 }] }], + accountsToSetup: [ + { assets: [{ token: "ETH", balance: ethInitialBalance }] }, + ], }) await expect(extension.network.networkSelector).toBeVisible() await extension.network.selectDefaultNetwork() - await extension.account.token("ETH").click() + await extension.account.send.click() await extension.account.recipientAddressQuery.type(config.account1Seed2!) await extension.addressBook.add.click() await expect(extension.addressBook.address).toHaveText( @@ -92,16 +96,18 @@ test.describe("Address Book", { tag: "@tx" }, () => { test("Add address from send window", async ({ extension }) => { await extension.setupWallet({ - accountsToSetup: [{ assets: [{ token: "ETH", balance: 0.002 }] }], + accountsToSetup: [ + { assets: [{ token: "ETH", balance: ethInitialBalance }] }, + ], }) await expect(extension.network.networkSelector).toBeVisible() await extension.network.selectDefaultNetwork() - await extension.account.token("ETH").click() - await extension.utils.setClipBoardContent(config.account1Seed2!) + await extension.account.send.click() + await extension.clipboard.setClipboardText(config.account1Seed2!) await extension.account.recipientAddressQuery.focus() - await extension.utils.paste() + await extension.clipboard.paste() await extension.account.saveAddress.click() await expect(extension.addressBook.address).toHaveText( @@ -121,15 +127,17 @@ test.describe("Address Book", { tag: "@tx" }, () => { }) }) - test.skip("Add address - starknet.id", async ({ extension }) => { + test("Add address - starknet.id", async ({ extension }) => { await extension.setupWallet({ - accountsToSetup: [{ assets: [{ token: "ETH", balance: 0.002 }] }], + accountsToSetup: [ + { assets: [{ token: "ETH", balance: ethInitialBalance }] }, + ], }) await expect(extension.network.networkSelector).toBeVisible() await extension.network.selectDefaultNetwork() - await extension.account.token("ETH").click() + await extension.account.send.click() await extension.account.recipientAddressQuery.type("qateste2e.stark") await extension.addressBook.add.click() await expect(extension.addressBook.address).toHaveText("qateste2e.stark") diff --git a/packages/e2e/extension/src/specs/dapps.spec.ts b/packages/e2e/src/specs/dapps.spec.ts similarity index 95% rename from packages/e2e/extension/src/specs/dapps.spec.ts rename to packages/e2e/src/specs/dapps.spec.ts index 3cc7bd4b1..ce8d10a5e 100644 --- a/packages/e2e/extension/src/specs/dapps.spec.ts +++ b/packages/e2e/src/specs/dapps.spec.ts @@ -2,9 +2,11 @@ import { expect } from "@playwright/test" import test from "../test" import { lang } from "../languages" -import { sleep } from "../../../shared/src/common" +import { sleep } from "../utils" import config from "../config" +const ethInitialBalance = 0.002 * Number(config.initialBalanceMultiplier) + test.describe("Dapps", () => { test( "connect from starknet.id", @@ -21,7 +23,7 @@ test.describe("Dapps", () => { await extension.dapps.accept.click() //check connect dapps await extension.navigation.showSettingsLocator.click() - await extension.settings.connectedDapps.click() + await extension.settings.authorizedDapps.click() await expect( extension.dapps.connectedDapps(extension.account.accountName1, 1), ).toBeVisible() @@ -50,7 +52,7 @@ test.describe("Dapps", () => { await extension.dapps.accept.click() //check connect dapps await extension.navigation.showSettingsLocator.click() - await extension.settings.connectedDapps.click() + await extension.settings.authorizedDapps.click() await expect( extension.dapps.connectedDapps(extension.account.accountName1, 1), ).toBeVisible() @@ -88,7 +90,7 @@ test.describe("Dapps", () => { //accept connection from Argent X await extension.dapps.accept.click() await extension.navigation.showSettingsLocator.click() - await extension.settings.connectedDapps.click() + await extension.settings.authorizedDapps.click() await expect( extension.dapps.connectedDapps(extension.account.accountName1, 2), ).toBeVisible() @@ -136,7 +138,7 @@ test.describe("Dapps", () => { //accept connection from Argent X await extension.dapps.accept.click() await extension.navigation.showSettingsLocator.click() - await extension.settings.connectedDapps.click() + await extension.settings.authorizedDapps.click() await expect( extension.dapps.connectedDapps(extension.account.accountName1, 2), ).toBeVisible() @@ -199,7 +201,7 @@ test.describe("Dapps", () => { //accept connection from Argent X await extension.dapps.accept.click() await extension.navigation.showSettingsLocator.click() - await extension.settings.connectedDapps.click() + await extension.settings.authorizedDapps.click() await Promise.all([ expect( extension.dapps.connectedDapps(extension.account.accountName1, 1), @@ -267,7 +269,10 @@ test.describe("Dapps", () => { //setup wallet await extension.setupWallet({ accountsToSetup: [ - { assets: [{ token: "ETH", balance: 0.001 }], deploy: true }, + { + assets: [{ token: "ETH", balance: ethInitialBalance }], + deploy: true, + }, ], }) await extension.open() @@ -349,7 +354,7 @@ test.describe("Dapps", () => { }, ) - test.skip("trying to sign message using testDapp with a smart account, wrong code", async ({ + test("trying to sign message using testDapp with a smart account, wrong code", async ({ extension, browserContext, }) => { diff --git a/packages/e2e/extension/src/specs/defaultAccount2smartAccount.spec.ts b/packages/e2e/src/specs/defaultAccount2smartAccount.spec.ts similarity index 85% rename from packages/e2e/extension/src/specs/defaultAccount2smartAccount.spec.ts rename to packages/e2e/src/specs/defaultAccount2smartAccount.spec.ts index 3d604d97d..19b39e9e0 100644 --- a/packages/e2e/extension/src/specs/defaultAccount2smartAccount.spec.ts +++ b/packages/e2e/src/specs/defaultAccount2smartAccount.spec.ts @@ -1,12 +1,13 @@ import { expect } from "@playwright/test" import test from "../test" -import { expireBESession } from "../../../shared/src/common" +import { expireBESession } from "../utils" import { v4 as uuid } from "uuid" import config from "./../config" import { lang } from "../languages" const generateEmail = () => `e2e_2fa_${uuid()}@mail.com` +const ethInitialBalance = 0.002 * Number(config.initialBalanceMultiplier) test.describe("Default account to Smart Account", { tag: "@tx" }, () => { test.skip(config.skipTXTests === "true") @@ -29,7 +30,10 @@ test.describe("Default account to Smart Account", { tag: "@tx" }, () => { const email = generateEmail() await extension.setupWallet({ accountsToSetup: [ - { assets: [{ token: "ETH", balance: 0.002 }], deploy: true }, + { + assets: [{ token: "ETH", balance: ethInitialBalance }], + deploy: true, + }, { assets: [{ token: "ETH", balance: 0 }] }, ], }) @@ -43,10 +47,10 @@ test.describe("Default account to Smart Account", { tag: "@tx" }, () => { token: "ETH", amount: "MAX", }) - - await extension.activity.checkActivity(1) + //todo: check activity + // await extension.activity.checkActivity(1) await extension.activity.ensureNoPendingTransactions() - await extension.navigation.menuTokensLocator.click() + //await extension.navigation.menuTokensLocator.click() //other accounts should have independent Argent Shield await extension.account.ensureSmartAccountNotEnabled( @@ -60,8 +64,14 @@ test.describe("Default account to Smart Account", { tag: "@tx" }, () => { const email = generateEmail() await extension.setupWallet({ accountsToSetup: [ - { assets: [{ token: "ETH", balance: 0.001 }], deploy: true }, - { assets: [{ token: "ETH", balance: 0.001 }], deploy: true }, + { + assets: [{ token: "ETH", balance: ethInitialBalance }], + deploy: true, + }, + { + assets: [{ token: "ETH", balance: ethInitialBalance }], + deploy: true, + }, ], }) await extension.activateSmartAccount({ @@ -93,7 +103,7 @@ test.describe("Default account to Smart Account", { tag: "@tx" }, () => { }) => { await extension.recoverWallet(config.senderSeed!) await extension.account.ensureSelectedAccount("Account 11") - await extension.account.token("ETH").click() + await extension.account.send.click() await extension.account.fillRecipientAddress({ recipientAddress: config.destinationAddress!, }) @@ -115,7 +125,10 @@ test.describe("Default account to Smart Account", { tag: "@tx" }, () => { const email = generateEmail() await extension.setupWallet({ accountsToSetup: [ - { assets: [{ token: "ETH", balance: 0.002 }], deploy: true }, + { + assets: [{ token: "ETH", balance: ethInitialBalance }], + deploy: true, + }, ], }) @@ -124,7 +137,7 @@ test.describe("Default account to Smart Account", { tag: "@tx" }, () => { email, }) await expireBESession(email) - await extension.account.token("ETH").click() + await extension.account.send.click() await extension.account.fillRecipientAddress({ recipientAddress: config.destinationAddress!, }) @@ -136,7 +149,8 @@ test.describe("Default account to Smart Account", { tag: "@tx" }, () => { await extension.account.sendMax.click() await extension.account.reviewSendLocator.click() await extension.account.confirmTransaction() - await extension.activity.checkActivity(1) + //todo: check activity + //await extension.activity.checkActivity(1) }) test("Try to upgrade to smart account with an email already in use", async ({ @@ -144,7 +158,10 @@ test.describe("Default account to Smart Account", { tag: "@tx" }, () => { }) => { await extension.setupWallet({ accountsToSetup: [ - { assets: [{ token: "ETH", balance: 0.001 }], deploy: true }, + { + assets: [{ token: "ETH", balance: ethInitialBalance }], + deploy: true, + }, ], }) await extension.account.ensureSelectedAccount( @@ -167,7 +184,10 @@ test.describe("Default account to Smart Account", { tag: "@tx" }, () => { }) => { await extension.setupWallet({ accountsToSetup: [ - { assets: [{ token: "ETH", balance: 0.001 }], deploy: true }, + { + assets: [{ token: "ETH", balance: ethInitialBalance }], + deploy: true, + }, ], }) const email = generateEmail() diff --git a/packages/e2e/src/specs/importAccounts.spec.ts b/packages/e2e/src/specs/importAccounts.spec.ts new file mode 100644 index 000000000..0e81d57ce --- /dev/null +++ b/packages/e2e/src/specs/importAccounts.spec.ts @@ -0,0 +1,87 @@ +import { expect } from "@playwright/test" +import config from "../config" +import test from "../test" +import { TokenSymbol, transferTokens } from "../utils" +const accountsToImport = JSON.parse(config.accountsToImport || "[]") +const txAccountAddress = config.accountToImportAndTx![0] +const txAccountPK = config.accountToImportAndTx![1] + +test.afterAll(({}) => { + transferTokens(0.01, config.accountToImportAndTx![0], "ETH") +}) + +test.describe("Import accounts", () => { + for (let i = 0; i < accountsToImport.length; i++) { + const account = accountsToImport[i] + test(`User should be able to import ${account.name} account`, async ({ + extension, + }) => { + await extension.wallet.newWalletOnboarding() + await extension.open() + await extension.account.importAccount({ + address: account.address, + privateKey: account.pk, + }) + await extension.account.selectAccount( + extension.account.importedAccountName1, + ) + const promises: Promise[] = [] + for (const [token, amount] of Object.entries(account.balance)) { + promises.push( + expect( + extension.account.currentBalance(token as TokenSymbol), + ).toContainText(`${amount}`), + ) + } + await Promise.all(promises) + }) + } + + test( + `User should be able to sign a TX with an imported account`, + { tag: "@tx" }, + async ({ extension }) => { + await extension.wallet.newWalletOnboarding() + await extension.open() + await extension.account.importAccount({ + address: txAccountAddress, + privateKey: txAccountPK, + }) + await extension.account.accountListSelector.click() + + await Promise.all([ + expect(extension.account.accountGroup("my-accounts")).toBeVisible(), + expect( + extension.account.accountGroup("imported-accounts"), + ).toBeVisible(), + ]) + await extension.navigation.closeButtonLocator.click() + const { sendAmountTX, sendAmountFE } = await extension.account.transfer({ + originAccountName: extension.account.importedAccountName1, + recipientAddress: config.senderAddrs![0], + token: "ETH", + amount: 0.005, + feeToken: "ETH", + }) + const txHash = await extension.activity.getLastTxHash() + await extension.validateTx({ + txHash: txHash!, + receiver: config.senderAddrs![0], + sendAmountFE, + sendAmountTX, + }) + }, + ) + + test(`User should not be able to import an account with an invalid private key`, async ({ + extension, + }) => { + await extension.wallet.newWalletOnboarding() + await extension.open() + await extension.account.importAccount({ + address: txAccountAddress, + privateKey: txAccountPK.replace("a", "b"), + validPK: false, + }) + }) +}) diff --git a/packages/e2e/extension/src/specs/invalidAddress.spec.ts b/packages/e2e/src/specs/invalidAddress.spec.ts similarity index 91% rename from packages/e2e/extension/src/specs/invalidAddress.spec.ts rename to packages/e2e/src/specs/invalidAddress.spec.ts index 1df46e1ab..e3bc43e57 100644 --- a/packages/e2e/extension/src/specs/invalidAddress.spec.ts +++ b/packages/e2e/src/specs/invalidAddress.spec.ts @@ -7,8 +7,7 @@ test.describe("Invalid addresses", { tag: "@all" }, () => { await extension.open() await extension.recoverWallet(config.senderSeed!) await expect(extension.network.networkSelector).toBeVisible() - - await extension.account.token("ETH").click() + await extension.account.send.click() await extension.account.fillRecipientAddress({ recipientAddress: "e2e-test5345346eertgegeggfgdgdgdfgdgdf.stark", validAddress: false, @@ -25,7 +24,7 @@ test.describe("Invalid addresses", { tag: "@all" }, () => { await extension.recoverWallet(config.senderSeed!) await expect(extension.network.networkSelector).toBeVisible() - await extension.account.token("ETH").click() + await extension.account.send.click() await extension.account.fillRecipientAddress({ recipientAddress: "0x0451fCcB2617Db213E0e661D525F16a52eCCF9E2b8D735f13E4F7de49A4Dc3a", @@ -39,7 +38,7 @@ test.describe("Invalid addresses", { tag: "@all" }, () => { await extension.recoverWallet(config.senderSeed!) await expect(extension.network.networkSelector).toBeVisible() - await extension.account.token("ETH").click() + await extension.account.send.click() await extension.account.fillRecipientAddress({ recipientAddress: "0x0451fCcB2617Db213E0e661D525F16a52eCCF9E2b8D735f13E4F7de49A4Dc3a3", @@ -53,7 +52,7 @@ test.describe("Invalid addresses", { tag: "@all" }, () => { await extension.recoverWallet(config.senderSeed!) await expect(extension.network.networkSelector).toBeVisible() - await extension.account.token("ETH").click() + await extension.account.send.click() await extension.account.fillRecipientAddress({ recipientAddress: "0x0451fCcB2617Db213E0e661D525F16a52eCCF9E2b8D735f13E4F7de49A4Dc3aq", diff --git a/packages/e2e/extension/src/specs/links.spec.ts b/packages/e2e/src/specs/links.spec.ts similarity index 100% rename from packages/e2e/extension/src/specs/links.spec.ts rename to packages/e2e/src/specs/links.spec.ts diff --git a/packages/e2e/extension/src/specs/multisig.spec.ts b/packages/e2e/src/specs/multisig.spec.ts similarity index 94% rename from packages/e2e/extension/src/specs/multisig.spec.ts rename to packages/e2e/src/specs/multisig.spec.ts index d79c9ebe9..aea829592 100644 --- a/packages/e2e/extension/src/specs/multisig.spec.ts +++ b/packages/e2e/src/specs/multisig.spec.ts @@ -1,7 +1,9 @@ import { expect } from "@playwright/test" import test from "../test" import config from "../config" -import { sleep } from "../../../shared/src/common" +import { sleep } from "../utils" + +const ethInitialBalance = 0.002 * Number(config.initialBalanceMultiplier) test.describe("Multisig", { tag: "@tx" }, () => { test.skip(config.skipTXTests === "true") @@ -17,7 +19,7 @@ test.describe("Multisig", { tag: "@tx" }, () => { ).toBeHidden() await extension.fundMultisigAccount({ accountName: extension.account.accountNameMulti1, - amount: 0.002, + balance: ethInitialBalance, }) await expect( extension.page.locator('[data-testid="activate-multisig"]'), @@ -28,7 +30,7 @@ test.describe("Multisig", { tag: "@tx" }, () => { originAccountName: extension.account.accountNameMulti1, recipientAddress: config.destinationAddress!, token: "ETH", - amount: 0.001, + amount: 0.0009, }) const txHash = await extension.activity.getLastTxHash() await extension.validateTx({ @@ -42,8 +44,14 @@ test.describe("Multisig", { tag: "@tx" }, () => { //ensure that balance is updated await expect(extension.account.currentBalance("ETH")).not.toContainText( - "0.002", + ethInitialBalance.toString(), ) + + await extension.account.accountListSelector.click() + await Promise.all([ + expect(extension.account.accountGroup("my-accounts")).toBeVisible(), + expect(extension.account.accountGroup("multisig-accounts")).toBeVisible(), + ]) }) test("add and activate 1/2 multisig", async ({ @@ -61,7 +69,7 @@ test.describe("Multisig", { tag: "@tx" }, () => { await extension.navigation.closeLocator.click() await extension.fundMultisigAccount({ accountName: extension.account.accountNameMulti1, - amount: 0.002, + balance: ethInitialBalance, }) await extension.activateMultisig(extension.account.accountNameMulti1) @@ -87,7 +95,7 @@ test.describe("Multisig", { tag: "@tx" }, () => { originAccountName: extension.account.accountNameMulti1, recipientAddress: config.destinationAddress!, token: "ETH", - amount: 0.001, + amount: 0.0009, }) const txHash = await extension.activity.getLastTxHash() await extension.validateTx({ @@ -102,10 +110,10 @@ test.describe("Multisig", { tag: "@tx" }, () => { //ensure that balance is updated await Promise.all([ expect(extension.account.currentBalance("ETH")).not.toContainText( - "0.002", + ethInitialBalance.toString(), ), expect(secondExtension.account.currentBalance("ETH")).not.toContainText( - "0.002", + ethInitialBalance.toString(), ), ]) await secondExtension.validateTx({ @@ -135,7 +143,7 @@ test.describe("Multisig", { tag: "@tx" }, () => { await extension.navigation.closeLocator.click() await extension.fundMultisigAccount({ accountName: extension.account.accountNameMulti1, - amount: 0.002, + balance: ethInitialBalance, }) await extension.activateMultisig(extension.account.accountNameMulti1) @@ -161,7 +169,7 @@ test.describe("Multisig", { tag: "@tx" }, () => { originAccountName: extension.account.accountNameMulti1, recipientAddress: config.destinationAddress!, token: "ETH", - amount: 0.001, + amount: 0.0009, }) await extension.navigation.menuActivityLocator.click() const txHash = await extension.activity.getLastTxHash() @@ -186,9 +194,11 @@ test.describe("Multisig", { tag: "@tx" }, () => { //ensure that balance is updated await Promise.all([ - expect(extension.account.currentBalance("ETH")).not.toContainText("0.02"), + expect(extension.account.currentBalance("ETH")).not.toContainText( + ethInitialBalance.toString(), + ), expect(secondExtension.account.currentBalance("ETH")).not.toContainText( - "0.02", + ethInitialBalance.toString(), ), ]) }) @@ -211,7 +221,7 @@ test.describe("Multisig", { tag: "@tx" }, () => { await extension.navigation.closeLocator.click() await extension.fundMultisigAccount({ accountName: extension.account.accountNameMulti1, - amount: 0.002, + balance: ethInitialBalance, }) await extension.activateMultisig(extension.account.accountNameMulti1) @@ -267,7 +277,7 @@ test.describe("Multisig", { tag: "@tx" }, () => { originAccountName: extension.account.accountNameMulti1, recipientAddress: config.destinationAddress!, token: "ETH", - amount: 0.001, + amount: 0.0009, }) txHash = await extension.activity.getLastTxHash() //wait for events to be updated @@ -303,9 +313,11 @@ test.describe("Multisig", { tag: "@tx" }, () => { //ensure that balance is updated await Promise.all([ - expect(extension.account.currentBalance("ETH")).not.toContainText("0.02"), + expect(extension.account.currentBalance("ETH")).not.toContainText( + ethInitialBalance.toString(), + ), expect(secondExtension.account.currentBalance("ETH")).not.toContainText( - "0.02", + ethInitialBalance.toString(), ), ]) }) @@ -328,7 +340,7 @@ test.describe("Multisig", { tag: "@tx" }, () => { await extension.navigation.closeLocator.click() await extension.fundMultisigAccount({ accountName: extension.account.accountNameMulti1, - amount: 0.002, + balance: ethInitialBalance, }) await extension.activateMultisig(extension.account.accountNameMulti1) @@ -379,7 +391,7 @@ test.describe("Multisig", { tag: "@tx" }, () => { extension.account.account(extension.account.accountNameMulti1), ).toBeVisible(), expect( - extension.account.account(extension.account.accountNameMulti2), + extension.account.account(extension.account.accountNameMulti6), ).toBeHidden(), expect( extension.account.account(extension.account.accountNameMulti3), @@ -448,7 +460,7 @@ test.describe("Multisig", { tag: "@tx" }, () => { ).toBeHidden() await extension.fundMultisigAccount({ accountName: extension.account.accountNameMulti1, - amount: 0.002, + balance: ethInitialBalance, }) await expect( extension.page.locator('[data-testid="activate-multisig"]'), @@ -491,7 +503,7 @@ test.describe("Multisig", { tag: "@tx" }, () => { originAccountName: extension.account.accountNameMulti1, recipientAddress: config.destinationAddress!, token: "ETH", - amount: 0.0001, + amount: 0.0009, }) const txHash = await extension.activity.getLastTxHash() await extension.navigation.menuTokensLocator.click() @@ -539,13 +551,13 @@ test.describe("Multisig", { tag: "@tx" }, () => { //ensure that balance is updated await Promise.all([ expect(extension.account.currentBalance("ETH")).not.toContainText( - "0.002", + ethInitialBalance.toString(), { timeout: 120000, }, ), expect(secondExtension.account.currentBalance("ETH")).not.toContainText( - "0.002", + ethInitialBalance.toString(), { timeout: 120000 }, ), ]) @@ -576,19 +588,19 @@ test.describe("Multisig", { tag: "@tx" }, () => { await extension.fundMultisigAccount({ accountName: extension.account.accountNameMulti1, - amount: 0.002, + balance: ethInitialBalance, }) await extension.activateMultisig(extension.account.accountNameMulti1) await extension.fundMultisigAccount({ accountName: extension.account.accountNameMulti2, - amount: 0.002, + balance: ethInitialBalance, }) await extension.activateMultisig(extension.account.accountNameMulti2) await extension.fundMultisigAccount({ accountName: extension.account.accountNameMulti3, - amount: 0.002, + balance: ethInitialBalance, }) await extension.activateMultisig(extension.account.accountNameMulti3) diff --git a/packages/e2e/extension/src/specs/network.spec.ts b/packages/e2e/src/specs/network.spec.ts similarity index 88% rename from packages/e2e/extension/src/specs/network.spec.ts rename to packages/e2e/src/specs/network.spec.ts index e3aebabad..5567511ff 100644 --- a/packages/e2e/extension/src/specs/network.spec.ts +++ b/packages/e2e/src/specs/network.spec.ts @@ -1,7 +1,7 @@ import { expect } from "@playwright/test" import test from "../test" -import config from "../../../shared/config" +import config from "../config" test.describe("Network", () => { test( @@ -16,7 +16,7 @@ test.describe("Network", () => { await extension.network.ensureAvailableNetworks([ "Mainnet", "Sepolia", - "Devnet\nhttp://localhost:5050", + "Devnet\n\nhttp://localhost:5050", ]) }, ) @@ -30,7 +30,7 @@ test.describe("Network", () => { await extension.wallet.newWalletOnboarding() await extension.open() await extension.navigation.showSettingsLocator.click() - await extension.settings.developerSettings.click() + await extension.settings.advancedSettings.click() await extension.developerSettings.manageNetworks.click() //add new network @@ -50,12 +50,16 @@ test.describe("Network", () => { } else { await extension.network.ensureSelectedNetwork("Mainnet") } - // select network + // add account await extension.network.selectNetwork("My Network") + await extension.account.addAccount({ firstAccount: true }) + // select network + await extension.network.selectNetwork("My Network") + await extension.navigation.closeLocator.click() // try to delete network await extension.navigation.showSettingsLocator.click() - await extension.settings.developerSettings.click() + await extension.settings.advancedSettings.click() await extension.developerSettings.manageNetworks.click() await extension.developerSettings .deleteNetworkByName("My Network") @@ -69,13 +73,11 @@ test.describe("Network", () => { await extension.navigation.backLocator.click() await extension.navigation.closeLocator.click() await extension.network.ensureSelectedNetwork("My Network") - await expect(extension.account.createAccount).toBeVisible() - await expect(extension.account.noAccountBanner).toBeVisible() // select other network await extension.network.selectDefaultNetwork() // delete network await extension.navigation.showSettingsLocator.click() - await extension.settings.developerSettings.click() + await extension.settings.advancedSettings.click() await extension.developerSettings.manageNetworks.click() await extension.developerSettings .deleteNetworkByName("My Network") @@ -92,7 +94,7 @@ test.describe("Network", () => { await extension.wallet.newWalletOnboarding() await extension.open() await extension.navigation.showSettingsLocator.click() - await extension.settings.developerSettings.click() + await extension.settings.advancedSettings.click() await extension.developerSettings.manageNetworks.click() //add new network @@ -116,7 +118,7 @@ test.describe("Network", () => { // try to restore networks await extension.navigation.showSettingsLocator.click() - await extension.settings.developerSettings.click() + await extension.settings.advancedSettings.click() await extension.developerSettings.manageNetworks.click() await extension.developerSettings.restoreDefaultNetworks.click() await expect( diff --git a/packages/e2e/extension/src/specs/nfts.spec.ts b/packages/e2e/src/specs/nfts.spec.ts similarity index 92% rename from packages/e2e/extension/src/specs/nfts.spec.ts rename to packages/e2e/src/specs/nfts.spec.ts index d94942631..94dacc968 100644 --- a/packages/e2e/extension/src/specs/nfts.spec.ts +++ b/packages/e2e/src/specs/nfts.spec.ts @@ -3,6 +3,8 @@ import { expect } from "@playwright/test" import config from "../config" import test from "../test" const spokCampaignName = `${config.spokCampaignName!}` +const ethInitialBalance = 0.002 * Number(config.initialBalanceMultiplier) + for (const feeToken of ["STRK", "ETH"] as const) { test.describe(`Nfts ${feeToken}`, { tag: "@tx" }, () => { test.skip( @@ -20,7 +22,7 @@ for (const feeToken of ["STRK", "ETH"] as const) { accountsToSetup: [ { assets: [ - { token: "ETH", balance: 0.01 }, + { token: "ETH", balance: ethInitialBalance }, { token: "STRK", balance: STRKBalance }, ], deploy: true, @@ -48,6 +50,7 @@ for (const feeToken of ["STRK", "ETH"] as const) { extension.activity.menuPendingTransactionsIndicatorLocator, ).toBeHidden() await dapp.close() + await extension.navigation.menuNTFsLocator.click() await extension.nfts.collection(spokCampaignName).click() await extension.nfts.nftByPosition().click() @@ -72,12 +75,15 @@ for (const feeToken of ["STRK", "ETH"] as const) { receiver: accountAddresses[1], txType: "nft", }) + + await extension.navigation.menuTokensLocator.click() await extension.navigation.menuNTFsLocator.click() await expect(extension.nfts.collection(spokCampaignName)).toBeHidden() await extension.account.ensureSelectedAccount( extension.account.accountName2, ) + await extension.navigation.menuNTFsLocator.click() await extension.nfts.collection(spokCampaignName).click() await extension.nfts.nftByPosition().click() }) diff --git a/packages/e2e/extension/src/specs/recovery.spec.ts b/packages/e2e/src/specs/recovery.spec.ts similarity index 87% rename from packages/e2e/extension/src/specs/recovery.spec.ts rename to packages/e2e/src/specs/recovery.spec.ts index f5c7df591..de2b1f3a3 100644 --- a/packages/e2e/extension/src/specs/recovery.spec.ts +++ b/packages/e2e/src/specs/recovery.spec.ts @@ -2,7 +2,7 @@ import { expect } from "@playwright/test" import config from "../config" import test from "../test" -import { logInfo } from "../../../shared/src/common" +import { logInfo } from "../utils" test.describe("Recovery Wallet", () => { test("User should be able to recover wallet using seed phrase after reset extension", async ({ @@ -11,7 +11,6 @@ test.describe("Recovery Wallet", () => { await extension.open() await extension.recoverWallet(config.testSeed3!) await expect(extension.network.networkSelector).toBeVisible() - await extension.resetExtension() await extension.recoverWallet(config.testSeed3!) }) @@ -19,20 +18,14 @@ test.describe("Recovery Wallet", () => { test( "Set up account recovery banner should not be visible after user copy phrase", { - tag: "@all", + tag: "@ProdOnly", }, async ({ extension }) => { await extension.wallet.newWalletOnboarding() await extension.open() await expect(extension.network.networkSelector).toBeVisible() - await extension.network.selectNetwork("Mainnet") - if (!config.isProdTesting) { - await extension.account.addAccountMainnet({ firstAccount: true }) - } await expect(extension.account.showAccountRecovery).toBeVisible() - if (config.isProdTesting) { - await extension.account.dismissAccountRecoveryBanner() - } + await extension.account.dismissAccountRecoveryBanner() await expect(extension.account.setUpAccountRecovery).toBeHidden() }, ) @@ -97,7 +90,8 @@ test.describe("Recovery Wallet", () => { await extension.account.accountAddressFromAssetsView.click() await extension.account.saveRecoveryPhrase() await extension.account.copyAddress.click() - const accountAddress = await extension.utils.getClipboard() + await extension.clipboard.setClipboard() + const accountAddress = await extension.clipboard.getClipboard() expect(accountAddress).toMatch(/^0x0/) }, ) @@ -105,27 +99,23 @@ test.describe("Recovery Wallet", () => { test( "Save your recovery phrase banner", { - tag: "@all", + tag: "@prodOnly", }, async ({ extension }) => { await extension.wallet.newWalletOnboarding() await extension.open() await expect(extension.network.networkSelector).toBeVisible() - await extension.network.selectNetwork("Mainnet") - if (!config.isProdTesting) { - await extension.account.addAccountMainnet({ firstAccount: true }) - } - await extension.account.showAccountRecovery.click() await extension.account.saveRecoveryPhrase() await extension.account.copyAddress.click() - const accountAddress = await extension.utils.getClipboard() + await extension.clipboard.setClipboard() + const accountAddress = await extension.clipboard.getClipboard() expect(accountAddress).toMatch(/^0x0/) await expect(extension.account.showAccountRecovery).toBeHidden() }, ) - test("User should be able to recover wallet with only one account with founds", async ({ + test("User should be able to recover wallet with only one account with funds", async ({ extension, }) => { await extension.open() diff --git a/packages/e2e/extension/src/specs/sendMaxFunds.spec.ts b/packages/e2e/src/specs/sendMaxFunds.spec.ts similarity index 92% rename from packages/e2e/extension/src/specs/sendMaxFunds.spec.ts rename to packages/e2e/src/specs/sendMaxFunds.spec.ts index cd6e91b9e..02f75e858 100644 --- a/packages/e2e/extension/src/specs/sendMaxFunds.spec.ts +++ b/packages/e2e/src/specs/sendMaxFunds.spec.ts @@ -2,7 +2,10 @@ import { expect } from "@playwright/test" import config from "../config" import test from "../test" -import { TokenSymbol } from "../../../shared/src/assets" +import { TokenSymbol } from "../utils" + +const ethInitialBalance = 0.01 * Number(config.initialBalanceMultiplier) + for (const feeToken of ["STRK", "ETH"] as const) { test.describe(`Send MAX funds fee ${feeToken}`, { tag: "@tx" }, () => { test.skip( @@ -15,11 +18,11 @@ for (const feeToken of ["STRK", "ETH"] as const) { const assets = feeToken === "STRK" ? [ - { token: "ETH" as TokenSymbol, balance: 0.01 }, + { token: "ETH" as TokenSymbol, balance: ethInitialBalance }, { token: "STRK" as TokenSymbol, balance: 2 }, ] : [ - { token: "ETH" as TokenSymbol, balance: 0.01 }, + { token: "ETH" as TokenSymbol, balance: ethInitialBalance }, { token: "STRK" as TokenSymbol, balance: 0.001 }, ] test("send MAX funds to other self account", async ({ extension }) => { @@ -89,7 +92,7 @@ for (const feeToken of ["STRK", "ETH"] as const) { expectedUpdatedBalance, ) const balance = await extension.account.currentBalance("ETH").innerText() - expect(parseFloat(balance)).toBeLessThan(0.0005) + expect(parseFloat(balance)).toBeLessThan(assets[0].balance) }) test.skip("User should be able to send funds to starknet id", async ({ diff --git a/packages/e2e/extension/src/specs/sendPartialFunds.spec.ts b/packages/e2e/src/specs/sendPartialFunds.spec.ts similarity index 92% rename from packages/e2e/extension/src/specs/sendPartialFunds.spec.ts rename to packages/e2e/src/specs/sendPartialFunds.spec.ts index c2d64695c..2343f7fc7 100644 --- a/packages/e2e/extension/src/specs/sendPartialFunds.spec.ts +++ b/packages/e2e/src/specs/sendPartialFunds.spec.ts @@ -2,7 +2,10 @@ import { expect } from "@playwright/test" import config from "../config" import test from "../test" -import { TokenSymbol } from "../../../shared/src/assets" +import { TokenSymbol } from "../utils" + +const ethInitialBalance = 0.01 * Number(config.initialBalanceMultiplier) + for (const feeToken of ["STRK", "ETH"] as const) { test.describe(`Send partial funds fee ${feeToken}`, { tag: "@tx" }, () => { test.skip( @@ -13,11 +16,11 @@ for (const feeToken of ["STRK", "ETH"] as const) { const assets = feeToken === "STRK" ? [ - { token: "ETH" as TokenSymbol, balance: 0.01 }, + { token: "ETH" as TokenSymbol, balance: ethInitialBalance }, { token: "STRK" as TokenSymbol, balance: 2 }, ] : [ - { token: "ETH" as TokenSymbol, balance: 0.01 }, + { token: "ETH" as TokenSymbol, balance: ethInitialBalance }, { token: "STRK" as TokenSymbol, balance: 0.001 }, ] test("send partial funds to other self account", async ({ extension }) => { @@ -101,7 +104,7 @@ for (const feeToken of ["STRK", "ETH"] as const) { }) }) - test.skip("User should be able to send funds to starknet id", async ({ + test("User should be able to send funds to starknet id", async ({ extension, }) => { await extension.setupWallet({ diff --git a/packages/e2e/extension/src/specs/smartAccount.spec.ts b/packages/e2e/src/specs/smartAccount.spec.ts similarity index 54% rename from packages/e2e/extension/src/specs/smartAccount.spec.ts rename to packages/e2e/src/specs/smartAccount.spec.ts index c38fb20a9..55f4a6d54 100644 --- a/packages/e2e/extension/src/specs/smartAccount.spec.ts +++ b/packages/e2e/src/specs/smartAccount.spec.ts @@ -1,11 +1,10 @@ import { expect } from "@playwright/test" import test from "../test" -import { expireBESession } from "../../../shared/src/common" -import { v4 as uuid } from "uuid" +import { expireBESession, generateEmail } from "../utils" import config from "../config" -const generateEmail = () => `e2e_2fa_${uuid()}@mail.com` +const ethInitialBalance = 0.002 * Number(config.initialBalanceMultiplier) test.describe("Smart Account", { tag: "@tx" }, () => { test.slow() @@ -18,7 +17,10 @@ test.describe("Smart Account", { tag: "@tx" }, () => { await extension.setupWallet({ email, accountsToSetup: [ - { assets: [{ token: "ETH", balance: 0.002 }], deploy: true }, + { + assets: [{ token: "ETH", balance: ethInitialBalance }], + deploy: true, + }, { assets: [{ token: "ETH", balance: 0 }] }, ], }) @@ -29,7 +31,8 @@ test.describe("Smart Account", { tag: "@tx" }, () => { token: "ETH", amount: "MAX", }) - await extension.activity.checkActivity(1) + //todo check activity + //await extension.activity.checkActivity(1) await extension.activity.ensureNoPendingTransactions() //other accounts should have independent Argent Shield @@ -45,8 +48,14 @@ test.describe("Smart Account", { tag: "@tx" }, () => { await extension.setupWallet({ email, accountsToSetup: [ - { assets: [{ token: "ETH", balance: 0.001 }], deploy: true }, - { assets: [{ token: "ETH", balance: 0.001 }], deploy: true }, + { + assets: [{ token: "ETH", balance: ethInitialBalance }], + deploy: true, + }, + { + assets: [{ token: "ETH", balance: ethInitialBalance }], + deploy: true, + }, ], }) @@ -77,12 +86,15 @@ test.describe("Smart Account", { tag: "@tx" }, () => { await extension.setupWallet({ email, accountsToSetup: [ - { assets: [{ token: "ETH", balance: 0.002 }], deploy: true }, + { + assets: [{ token: "ETH", balance: ethInitialBalance }], + deploy: true, + }, ], }) await expireBESession(email) - await extension.account.token("ETH").click() + await extension.account.send.click() await extension.account.fillRecipientAddress({ recipientAddress: config.destinationAddress!, }) @@ -95,7 +107,8 @@ test.describe("Smart Account", { tag: "@tx" }, () => { await extension.account.sendMax.click() await extension.account.reviewSendLocator.click() await extension.account.confirmTransaction() - await extension.activity.checkActivity(1) + //todo: check activity + // await extension.activity.checkActivity(1) }) test("Try to setup a wallet with smart account with an email already in use", async ({ @@ -105,8 +118,53 @@ test.describe("Smart Account", { tag: "@tx" }, () => { email: "registeredemail@argent.xyz", success: false, accountsToSetup: [ - { assets: [{ token: "ETH", balance: 0.001 }], deploy: true }, + { + assets: [{ token: "ETH", balance: ethInitialBalance }], + deploy: true, + }, ], }) }) + + test("Recover a wallet which has smart account, downgrade, restore account, user should be able to TX without email/code", async ({ + extension, + }) => { + const email = generateEmail() + const { seed } = await extension.setupWallet({ + email, + accountsToSetup: [ + { + assets: [{ token: "ETH", balance: ethInitialBalance }], + deploy: true, + }, + { assets: [{ token: "ETH", balance: 0 }] }, + ], + }) + + await extension.account.transfer({ + originAccountName: extension.account.accountName1, + recipientAddress: config.destinationAddress!, + token: "ETH", + amount: 0.00001, + }) + + await extension.activity.ensureNoPendingTransactions() + + await extension.resetExtension() + await extension.recoverWallet(seed) + await extension.changeToStandardAccount({ + accountName: extension.account.accountName1, + email, + validSession: false, + }) + await extension.navigation.backLocator.click() + await extension.navigation.closeLocator.click() + await extension.account.transfer({ + originAccountName: extension.account.accountName1, + recipientAddress: config.destinationAddress!, + token: "ETH", + amount: "MAX", + }) + await extension.activity.ensureNoPendingTransactions() + }) }) diff --git a/packages/e2e/src/specs/swap.spec.ts b/packages/e2e/src/specs/swap.spec.ts new file mode 100644 index 000000000..abdf452b2 --- /dev/null +++ b/packages/e2e/src/specs/swap.spec.ts @@ -0,0 +1,118 @@ +import { expect } from "@playwright/test" +import { FeeTokens, TokenSymbol } from "../utils" +import test from "../test" + +const testParams = [ + { + feeToken: "STRK", + swapAmount: 0.1, + deployed: false, + ethAmount: 0.11, + strkAmount: 0.05, + }, + { + feeToken: "STRK", + swapAmount: 0.1, + deployed: true, + ethAmount: 0.11, + strkAmount: 0.05, + }, + { + feeToken: "STRK", + swapAmount: "MAX", + deployed: false, + ethAmount: 0.11, + strkAmount: 0.05, + }, + { + feeToken: "STRK", + swapAmount: "MAX", + deployed: true, + ethAmount: 0.11, + strkAmount: 0.05, + }, + { + feeToken: "ETH", + swapAmount: 0.1, + deployed: false, + ethAmount: 0.11, + strkAmount: 0, + }, + { + feeToken: "ETH", + swapAmount: 0.1, + deployed: true, + ethAmount: 0.11, + strkAmount: 0, + }, + { + feeToken: "ETH", + swapAmount: "MAX", + deployed: false, + ethAmount: 0.11, + strkAmount: 0, + }, + { + feeToken: "ETH", + swapAmount: "MAX", + deployed: true, + ethAmount: 0.11, + strkAmount: 0, + }, +] +const receiveToken = "DAI" +const payToken = "ETH" +testParams.forEach((params) => { + test.describe.skip(`Swap tests`, () => { + test(`Swap ETH to DAI, using ${params.feeToken} as fee token, amount ${params.swapAmount}, deployed account ${params.deployed}`, async ({ + extension, + }) => { + const assets = [ + { token: "ETH" as TokenSymbol, balance: params.ethAmount }, + ] + if (params.strkAmount > 0) { + assets.push({ + token: "STRK" as TokenSymbol, + balance: params.strkAmount, + }) + } + await extension.setupWallet({ + accountsToSetup: [ + { + assets, + deploy: params.deployed, + feeToken: params.feeToken as FeeTokens, + }, + ], + }) + await extension.account.ensureSelectedAccount( + extension.account.accountName1, + ) + await extension.navigation.menuSwapsLocator.click() + const swappedAmount = await extension.swap.swapTokens({ + payToken, + receiveToken, + amount: params.swapAmount as number | "MAX", + alreadyDeployed: params.deployed, + }) + if (!swappedAmount) { + throw new Error("swappedAmount is undefined") + } + await expect( + extension.activity.menuPendingTransactionsIndicatorLocator, + ).toBeVisible() + + await expect( + extension.activity.menuPendingTransactionsIndicatorLocator, + ).toBeHidden() + + await extension.navigation.menuTokensLocator.click() + await expect(extension.account.currentBalance(receiveToken)).toBeVisible() + + const balance = await extension.account + .currentBalance(payToken) + .innerText() + expect(parseFloat(balance)).toBeLessThan(params.ethAmount) + }) + }) +}) diff --git a/packages/e2e/src/specs/tokenDetails.spec.ts b/packages/e2e/src/specs/tokenDetails.spec.ts new file mode 100644 index 000000000..bfc8371aa --- /dev/null +++ b/packages/e2e/src/specs/tokenDetails.spec.ts @@ -0,0 +1,185 @@ +import { expect } from "@playwright/test" + +import test from "../test" + +test.describe("Token Details", () => { + test( + "User should be able to see token details", + { + tag: "@prodOnly", + }, + async ({ extension }) => { + await extension.wallet.newWalletOnboarding() + await extension.open() + await expect(extension.network.networkSelector).toBeVisible() + await expect(extension.network.networkSelector).toContainText( + extension.network.getDefaultNetworkName(), + ) + await extension.account.setupRecovery() + await extension.tokenDetails.openTokenDetails("ETH").click() + + await expect(extension.tokenDetails.swapButtonLoc).toHaveCount(1) + await Promise.all([ + expect(extension.tokenDetails.swapButtonLoc).toBeVisible(), + expect(extension.tokenDetails.sendButtonLoc).toBeVisible(), + expect(extension.tokenDetails.buyButtonLoc).toBeVisible(), + ]) + extension.navigation.backLocator.click() + + await extension.tokenDetails.openTokenDetails("STRK").click() + await expect(extension.tokenDetails.swapButtonLoc).toHaveCount(1) + await Promise.all([ + expect(extension.tokenDetails.swapButtonLoc).toBeVisible(), + expect(extension.tokenDetails.sendButtonLoc).toBeVisible(), + expect(extension.tokenDetails.buyButtonLoc).toBeVisible(), + ]) + + await expect(extension.tokenDetails.graphTimeFrameLoc("1D")).toBeVisible() + + await Promise.all([ + expect(extension.tokenDetails.graphTimeFrameLoc("1D")).toBeVisible(), + expect(extension.tokenDetails.graphTimeFrameLoc("1W")).toBeVisible(), + expect(extension.tokenDetails.graphTimeFrameLoc("1M")).toBeVisible(), + expect(extension.tokenDetails.graphTimeFrameLoc("1Y")).toBeVisible(), + expect(extension.tokenDetails.graphTimeFrameLoc("All")).toBeVisible(), + ]) + const selectedButton = await extension.tokenDetails + .graphTimeFrameLoc("1D") + .evaluate((el, attr) => el.hasAttribute(attr), "data-active") + expect(selectedButton).toBe(true) + + await extension.tokenDetails.buyButtonLoc.click() + await expect(extension.navigation.backLocator).toBeVisible() + await expect(extension.page.getByText("Choose provider")).toBeVisible() + await extension.navigation.backLocator.click() + + await extension.tokenDetails.swapButtonLoc.click() + await expect(extension.swap.swapHeader).toBeVisible() + await extension.navigation.backLocator.click() + + await extension.tokenDetails.sendButtonLoc.click() + await expect(extension.account.sendToHeader).toBeVisible() + await extension.navigation.backLocator.click() + + await extension.tokenDetails.aboutButtonLoc.click() + await Promise.all([ + expect(extension.page.getByText("Market cap")).toBeVisible(), + expect(extension.page.getByText("24hr volume")).toBeVisible(), + ]) + + await extension.tokenDetails.activityButtonLoc.click() + await expect(extension.page.getByText("No activity")).toBeVisible() + + await extension.tokenDetails.menuButtonLoc.click() + await Promise.all([ + expect(extension.tokenDetails.menuCopyTokenAddressLoc).toBeVisible(), + expect(extension.tokenDetails.menuViewOnVoyagerLoc).toBeVisible(), + ]) + + await extension.tokenDetails.menuCopyTokenAddressLoc.click() + await extension.clipboard.setClipboard() + const tokenAddress = await extension.clipboard.getClipboard() + await extension.tokenDetails.menuViewOnVoyagerLoc + .locator(`[data-address="${tokenAddress}"]`) + .isVisible() + }, + ) + + test( + "User should be able to see hide tokens", + { + tag: "@prodOnly", + }, + async ({ extension }) => { + await extension.wallet.newWalletOnboarding() + await extension.open() + await expect(extension.network.networkSelector).toBeVisible() + await expect(extension.network.networkSelector).toContainText( + extension.network.getDefaultNetworkName(), + ) + await extension.account.setupRecovery() + + await extension.tokenDetails.addNewToken( + "0x053c91253bc9682c04929ca02ed00b3e423f6710d2ee7e0d5ebb06f3ecf368a8", + "USDC", + ) + + await extension.navigation.showSettingsLocator.click() + await extension.settings.preferences.click() + await extension.preferences.hiddenAndSpamTokens.click() + + await expect(extension.tokenDetails.token("USD Coin")).toBeVisible({ + timeout: 1000 * 60 * 2, + }) + + await extension.navigation.backLocator.click() + await expect(extension.preferences.hiddenAndSpamTokens).toBeVisible() + await extension.navigation.backLocator.click() + await extension.navigation.closeLocator.click() + + await expect(extension.account.token("USDC")).toBeVisible() + + await extension.navigation.showSettingsLocator.click() + await extension.settings.preferences.click() + await extension.preferences.hiddenAndSpamTokens.click() + await extension.tokenDetails.hideToken("USDC").click() + + await extension.navigation.backLocator.click() + await expect(extension.preferences.hiddenAndSpamTokens).toBeVisible() + await extension.navigation.backLocator.click() + await extension.navigation.closeLocator.click() + + await expect(extension.account.token("USDC")).toBeHidden() + }, + ) + + test( + "User should be able to see hide Spam tokens", + { + tag: "@prodOnly", + }, + async ({ extension }) => { + await extension.wallet.newWalletOnboarding() + await extension.open() + await expect(extension.network.networkSelector).toBeVisible() + await expect(extension.network.networkSelector).toContainText( + extension.network.getDefaultNetworkName(), + ) + await extension.account.setupRecovery() + + await extension.tokenDetails.addNewToken( + "0x001f4466085c4bb3374ecad67bfcb4cce25ea502617ab624cf532f90300f2794", + "ádfas", + ) + + await extension.navigation.showSettingsLocator.click() + await extension.settings.preferences.click() + await extension.preferences.hiddenAndSpamTokens.click() + await extension.tokenDetails.spamTokensList.click() + await expect( + extension.tokenDetails.token("HahaHahaHahaHahaHahaHahaHaha"), + ).toBeVisible({ timeout: 1000 * 60 * 2 }) + + await extension.navigation.backLocator.click() + await expect(extension.preferences.hiddenAndSpamTokens).toBeVisible() + await extension.navigation.backLocator.click() + await extension.navigation.closeLocator.click() + + await expect(extension.account.token("ádfas")).toBeHidden() + + await extension.navigation.showSettingsLocator.click() + await extension.settings.preferences.click() + await extension.preferences.hiddenAndSpamTokens.click() + await extension.tokenDetails.spamTokensList.click() + await extension.tokenDetails.spamTokensList.click() + await extension.tokenDetails.showToken("ádfas").click() + + await extension.navigation.backLocator.click() + await expect(extension.preferences.hiddenAndSpamTokens).toBeVisible() + await extension.navigation.backLocator.click() + await extension.navigation.closeLocator.click() + + await expect(extension.account.token("ádfas")).toBeVisible() + }, + ) +}) diff --git a/packages/e2e/src/specs/tokens.spec.ts b/packages/e2e/src/specs/tokens.spec.ts new file mode 100644 index 000000000..1486e15e9 --- /dev/null +++ b/packages/e2e/src/specs/tokens.spec.ts @@ -0,0 +1,35 @@ +import { expect } from "@playwright/test" + +import test from "../test" +import { transferTokens } from "../utils" + +const usdcAmount = "0.000002" +const ethAmount = "0.00000001" +test.describe("Tokens", { tag: "@tx" }, () => { + test("Token should be auto discovered", async ({ extension }) => { + const { accountAddresses } = await extension.setupWallet({ + accountsToSetup: [{ assets: [{ token: "ETH", balance: +ethAmount }] }], + }) + + //ensure that balance is updated + await Promise.all([ + expect(extension.account.currentBalance("ETH")).toHaveText( + `${ethAmount} ETH`, + ), + expect(extension.account.currentBalance("USDC")).toBeHidden(), + ]) + + await transferTokens(+usdcAmount, accountAddresses[0], "USDC") + //ensure that balance is updated + await Promise.all([ + expect(extension.account.currentBalance("ETH")).toHaveText( + `${ethAmount} ETH`, + ), + expect(extension.account.currentBalance("STRK")).toHaveText("0.0 STRK"), + expect(extension.account.currentBalance("USDC")).toHaveText( + `${usdcAmount} USDC`, + { timeout: 180 * 1000 }, + ), + ]) + }) +}) diff --git a/packages/e2e/src/specs/upgrade.spec.ts b/packages/e2e/src/specs/upgrade.spec.ts new file mode 100644 index 000000000..c74e5ff55 --- /dev/null +++ b/packages/e2e/src/specs/upgrade.spec.ts @@ -0,0 +1,406 @@ +import { expect } from "@playwright/test" + +import config from "../config" +import test from "../test" +import { + downloadFile, + generateEmail, + getBalance, + transferTokens, +} from "../utils" + +const ethInitialBalance = 0.01 * Number(config.initialBalanceMultiplier) +const versions = ["5.19.4"] +const downloadBuild = async (version: string) => { + try { + await downloadFile(version) + } catch (err) { + if (err instanceof Error) { + console.error("Error:", err.message) + } else { + console.error("An unknown error occurred") + } + } +} +test.describe.serial("Upgrade Extension", { tag: "@upgrade" }, () => { + versions.forEach((version) => { + test.slow() + test(`${version}: After upgrade, tokens and balances should be the same`, async ({ + upgradeExtension, + }) => { + await downloadBuild(version) + //fetch balance from blockchain + const [ethBalance, strkBalance] = await Promise.all([ + getBalance(config.migAccountAddress!, "ETH"), + getBalance(config.migAccountAddress!, "STRK"), + ]) + await upgradeExtension.setExtensionVersion(version) + + await upgradeExtension.recoverWallet(config.testSeed3!) + await expect(upgradeExtension.network.networkSelector).toBeVisible() + + //check balance + await Promise.all([ + expect(upgradeExtension.account.currentBalance("ETH")).toContainText( + ethBalance, + ), + expect(upgradeExtension.account.currentBalance("STRK")).toContainText( + strkBalance, + ), + ]) + + //upgrade extension + await upgradeExtension.upgradeExtension( + upgradeExtension.currentBranchVersion, + ) + + //ensure that tokens are present and not duplicated + await Promise.all([ + expect(upgradeExtension.account.currentBalance("ETH")).toHaveCount(1), + expect(upgradeExtension.account.currentBalance("STRK")).toHaveCount(1), + ]) + + //check balance + await Promise.all([ + expect(upgradeExtension.account.currentBalance("ETH")).toContainText( + ethBalance, + ), + expect(upgradeExtension.account.currentBalance("STRK")).toContainText( + strkBalance, + ), + ]) + }) + + test(`${version}: Create a Smart account, leave it unfunded and undeployed`, async ({ + upgradeExtension, + }) => { + await downloadBuild(version) + + await upgradeExtension.setExtensionVersion(version) + + const email = generateEmail() + const { accountAddresses } = await upgradeExtension.setupWallet({ + email, + accountsToSetup: [{ assets: [{ token: "ETH", balance: 0 }] }], + }) + await expect(upgradeExtension.network.networkSelector).toBeVisible() + //upgrade extension + await upgradeExtension.upgradeExtension( + upgradeExtension.currentBranchVersion, + ) + await transferTokens(ethInitialBalance, accountAddresses[0], "ETH") + await upgradeExtension.deployAccount( + upgradeExtension.account.accountName1, + ) + }) + + test(`${version}: Create regular account, deploy, upgrade, do a TX, add and deploy new account`, async ({ + upgradeExtension, + }) => { + await downloadBuild(version) + await upgradeExtension.setExtensionVersion(version) + + await upgradeExtension.setupWallet({ + accountsToSetup: [ + { + assets: [{ token: "ETH", balance: ethInitialBalance }], + deploy: true, + }, + ], + }) + await expect(upgradeExtension.network.networkSelector).toBeVisible() + //upgrade extension + await upgradeExtension.upgradeExtension( + upgradeExtension.currentBranchVersion, + ) + const { accountAddress } = await upgradeExtension.account.addAccount({ + firstAccount: false, + }) + + const { sendAmountTX, sendAmountFE } = + await upgradeExtension.account.transfer({ + originAccountName: upgradeExtension.account.accountName1, + recipientAddress: accountAddress!, + token: "ETH", + amount: "MAX", + }) + + const txHash = await upgradeExtension.activity.getLastTxHash() + await upgradeExtension.validateTx({ + txHash: txHash!, + receiver: accountAddress!, + sendAmountFE, + sendAmountTX, + }) + + await upgradeExtension.deployAccount( + upgradeExtension.account.accountName2, + ) + + await upgradeExtension.account.transfer({ + originAccountName: upgradeExtension.account.accountName2, + recipientAddress: config.destinationAddress!, + token: "ETH", + amount: "MAX", + }) + }) + + test(`${version}: Create a Smart account, deploy it, upgrade, do a TX`, async ({ + upgradeExtension, + }) => { + await downloadBuild(version) + + await upgradeExtension.setExtensionVersion(version) + + const email = generateEmail() + await upgradeExtension.setupWallet({ + email, + accountsToSetup: [ + { + assets: [{ token: "ETH", balance: ethInitialBalance }], + deploy: true, + }, + ], + }) + await expect(upgradeExtension.network.networkSelector).toBeVisible() + //upgrade extension + await upgradeExtension.upgradeExtension( + upgradeExtension.currentBranchVersion, + ) + await upgradeExtension.account.transfer({ + originAccountName: upgradeExtension.account.accountName1, + recipientAddress: config.destinationAddress!, + token: "ETH", + amount: "MAX", + }) + }) + + test(`${version}: Create regular account, fund it with ETH and USDC, upgrade, deploy, do a TX, check USDC is visible`, async ({ + upgradeExtension, + }) => { + const usdcAmount = "0.000002" + await downloadBuild(version) + await upgradeExtension.setExtensionVersion(version) + + const { accountAddresses } = await upgradeExtension.setupWallet({ + accountsToSetup: [ + { assets: [{ token: "ETH", balance: ethInitialBalance }] }, + ], + }) + await transferTokens(+usdcAmount, accountAddresses[0], "USDC") + await expect(upgradeExtension.account.currentBalance("USDC")).toHaveText( + `${usdcAmount} USDC`, + { timeout: 180 * 1000 }, + ) + //upgrade extension + await upgradeExtension.upgradeExtension( + upgradeExtension.currentBranchVersion, + ) + + await expect(upgradeExtension.account.currentBalance("USDC")).toHaveText( + `${usdcAmount} USDC`, + { timeout: 180 * 1000 }, + ) + await upgradeExtension.deployAccount( + upgradeExtension.account.accountName1, + ) + + await upgradeExtension.account.transfer({ + originAccountName: upgradeExtension.account.accountName1, + recipientAddress: config.destinationAddress!, + token: "ETH", + amount: "MAX", + }) + }) + + test(`${version}: Create regular account, deploy it, login with a valid email, upgrade, activate 2FA, do a TX`, async ({ + upgradeExtension, + }) => { + await downloadBuild(version) + await upgradeExtension.setExtensionVersion(version) + + await upgradeExtension.setupWallet({ + accountsToSetup: [ + { + assets: [{ token: "ETH", balance: ethInitialBalance }], + deploy: true, + }, + ], + }) + await expect(upgradeExtension.network.networkSelector).toBeVisible() + + // login with a valid email + const email = generateEmail() + await upgradeExtension.navigation.showSettingsLocator.click() + await upgradeExtension.settings.signIn(email) + + //upgrade extension + await upgradeExtension.upgradeExtension( + upgradeExtension.currentBranchVersion, + ) + await upgradeExtension.activateSmartAccount({ + accountName: upgradeExtension.account.accountName1, + validSession: true, + }) + await upgradeExtension.account.transfer({ + originAccountName: upgradeExtension.account.accountName1, + recipientAddress: config.destinationAddress!, + token: "ETH", + amount: "MAX", + }) + }) + + test(`${version}: Create regular accounts, edit account names, upgrade, check account names`, async ({ + upgradeExtension, + }) => { + await downloadBuild(version) + await upgradeExtension.setExtensionVersion(version) + + await upgradeExtension.setupWallet({ + accountsToSetup: [ + { + assets: [{ token: "ETH", balance: 0 }], + }, + { + assets: [{ token: "ETH", balance: 0 }], + }, + ], + }) + await expect(upgradeExtension.network.networkSelector).toBeVisible() + await upgradeExtension.account.ensureSelectedAccount( + upgradeExtension.account.accountName1, + ) + + await upgradeExtension.navigation.showSettingsLocator.click() + await upgradeExtension.settings + .account(upgradeExtension.account.accountName1) + .click() + await upgradeExtension.settings.setAccountName("My new account name 1") + await expect(upgradeExtension.settings.accountName).toHaveValue( + "My new account name 1", + ) + await upgradeExtension.navigation.backLocator.click() + await upgradeExtension.navigation.closeLocator.click() + + await upgradeExtension.account.ensureSelectedAccount( + "My new account name 1", + ) + await upgradeExtension.account.ensureSelectedAccount( + upgradeExtension.account.accountName2, + ) + + await upgradeExtension.navigation.showSettingsLocator.click() + await upgradeExtension.settings + .account(upgradeExtension.account.accountName2) + .click() + await upgradeExtension.settings.setAccountName("My new account name 2") + await expect(upgradeExtension.settings.accountName).toHaveValue( + "My new account name 2", + ) + await upgradeExtension.navigation.backLocator.click() + await upgradeExtension.navigation.closeLocator.click() + + await upgradeExtension.account.ensureSelectedAccount( + "My new account name 2", + ) + //upgrade extension + await upgradeExtension.upgradeExtension( + upgradeExtension.currentBranchVersion, + ) + + await upgradeExtension.account.ensureSelectedAccount( + "My new account name 1", + ) + await upgradeExtension.account.ensureSelectedAccount( + "My new account name 2", + ) + }) + + test(`${version}: create Multisig 1/1 account, upgrade, do tx`, async ({ + upgradeExtension, + }) => { + await downloadBuild(version) + await upgradeExtension.setExtensionVersion(version) + await upgradeExtension.setupWallet({ + accountsToSetup: [], + }) + await upgradeExtension.account.addMultisigAccount({}) + await upgradeExtension.navigation.closeLocator.click() + await expect( + upgradeExtension.page.locator('[data-testid="activate-multisig"]'), + ).toBeHidden() + await upgradeExtension.fundMultisigAccount({ + accountName: upgradeExtension.account.accountNameMulti1, + balance: ethInitialBalance, + }) + await expect( + upgradeExtension.page.locator('[data-testid="activate-multisig"]'), + ).toBeVisible() + await upgradeExtension.activateMultisig( + upgradeExtension.account.accountNameMulti1, + ) + + //upgrade extension + await upgradeExtension.upgradeExtension( + upgradeExtension.currentBranchVersion, + ) + + const { sendAmountTX, sendAmountFE } = + await upgradeExtension.account.transfer({ + originAccountName: upgradeExtension.account.accountNameMulti1, + recipientAddress: config.destinationAddress!, + token: "ETH", + amount: "MAX", + }) + const txHash = await upgradeExtension.activity.getLastTxHash() + await upgradeExtension.validateTx({ + txHash: txHash!, + receiver: config.destinationAddress!, + sendAmountFE, + sendAmountTX, + uniqLocator: true, + }) + }) + + test(`${version}: Create regular account, deploy, recover, add new account, fund, do not deploy, upgrade, deploy account`, async ({ + upgradeExtension, + }) => { + await downloadBuild(version) + await upgradeExtension.setExtensionVersion(version) + + const { seed } = await upgradeExtension.setupWallet({ + accountsToSetup: [ + { + assets: [{ token: "ETH", balance: 0 }], + }, + ], + }) + await expect(upgradeExtension.network.networkSelector).toBeVisible() + const { accountAddress } = await upgradeExtension.account.addAccount({ + firstAccount: false, + }) + + await transferTokens(ethInitialBalance, accountAddress!, "ETH") + + await upgradeExtension.resetExtension() + await upgradeExtension.recoverWallet(seed) + await expect(upgradeExtension.network.networkSelector).toBeVisible() + + //upgrade extension + await upgradeExtension.upgradeExtension( + upgradeExtension.currentBranchVersion, + ) + + await upgradeExtension.deployAccount( + upgradeExtension.account.accountName2, + ) + + await upgradeExtension.account.transfer({ + originAccountName: upgradeExtension.account.accountName2, + recipientAddress: config.destinationAddress!, + token: "ETH", + amount: "MAX", + }) + }) + }) +}) diff --git a/packages/e2e/extension/src/specs/welcome.spec.ts b/packages/e2e/src/specs/welcome.spec.ts similarity index 92% rename from packages/e2e/extension/src/specs/welcome.spec.ts rename to packages/e2e/src/specs/welcome.spec.ts index cb1059342..50498172d 100644 --- a/packages/e2e/extension/src/specs/welcome.spec.ts +++ b/packages/e2e/src/specs/welcome.spec.ts @@ -27,7 +27,7 @@ test.describe("Welcome screen", () => { await extension.wallet.newWalletOnboarding() await extension.open() await expect(extension.network.networkSelector).toBeVisible() - await expect(extension.network.networkSelector).toHaveText( + await expect(extension.network.networkSelector).toContainText( extension.network.getDefaultNetworkName(), ) }, diff --git a/packages/e2e/src/test.ts b/packages/e2e/src/test.ts new file mode 100644 index 000000000..8490a039e --- /dev/null +++ b/packages/e2e/src/test.ts @@ -0,0 +1,183 @@ +import { + ChromiumBrowserContext, + Page, + TestInfo, + chromium, + test as testBase, +} from "@playwright/test" +import { v4 as uuid } from "uuid" +import type { TestExtensions } from "./fixtures" +import ExtensionPage from "./page-objects/ExtensionPage" +import config from "./config" +import { logInfo } from "./utils" +import path from "path" +import fs from "fs-extra" + +declare global { + interface Window { + PLAYWRIGHT?: boolean + } +} +const outputFolder = (testInfo: TestInfo) => + testInfo.title.replace(/\s+/g, "_").replace(/\W/g, "") +const artifactFilename = (testInfo: TestInfo, label: string) => + `${testInfo.retry}-${testInfo.status}-${label}-${testInfo.workerIndex}` +const isKeepArtifacts = (testInfo: TestInfo) => + testInfo.config.preserveOutput === "always" || + (testInfo.config.preserveOutput === "failures-only" && + testInfo.status === "failed") || + testInfo.status === "timedOut" + +const artifactSetup = async (testInfo: TestInfo, label: string) => { + await fs.promises + .mkdir(path.resolve(config.artifactsDir, outputFolder(testInfo)), { + recursive: true, + }) + .catch((error) => { + console.error({ op: "artifactSetup", error }) + }) + return artifactFilename(testInfo, label) +} + +const saveHtml = async (testInfo: TestInfo, page: Page, label: string) => { + logInfo({ + op: "saveHtml", + label, + }) + const fileName = await artifactSetup(testInfo, label) + const htmlContent = await page.content() + await fs.promises + .writeFile( + path.resolve( + config.artifactsDir, + outputFolder(testInfo), + `${fileName}.html`, + ), + htmlContent, + ) + .catch((error) => { + console.error({ op: "saveHtml", error }) + }) +} + +const keepVideos = async (testInfo: TestInfo, page: Page, label: string) => { + logInfo({ + op: "keepVideos", + label, + }) + const fileName = await artifactSetup(testInfo, label) + await page + .video() + ?.saveAs( + path.resolve( + config.artifactsDir, + outputFolder(testInfo), + `${fileName}.webm`, + ), + ) + .catch((error) => { + console.error({ op: "keepVideos", error }) + }) +} + +const isExtensionURL = (url: string) => url.startsWith("chrome-extension://") +let browserCtx: ChromiumBrowserContext +const closePages = async (browserContext: ChromiumBrowserContext) => { + const pages = browserContext?.pages() || [] + for (const page of pages) { + if (!isExtensionURL(page.url())) { + await page.close() + } + } +} + +const createBrowserContext = async (userDataDir: string, buildDir: string) => { + const context = await chromium.launchPersistentContext(userDataDir, { + headless: false, + args: [ + "--disable-dev-shm-usage", + "--ipc=host", + `--disable-extensions-except=${buildDir}`, + `--load-extension=${buildDir}`, + ], + viewport: config.viewportSize, + ignoreDefaultArgs: ["--disable-component-extensions-with-background-pages"], + recordVideo: { + dir: config.artifactsDir, + size: config.viewportSize, + }, + }) + await context.addInitScript(() => { + window.PLAYWRIGHT = true + window.localStorage.setItem( + "seenNetworkStatusState", + JSON.stringify({ state: { lastSeen: Date.now() }, version: 0 }), + ) + window.localStorage.setItem("onboardingExperiment", "E1A1") + }) + return context +} + +const initBrowserWithExtension = async ( + userDataDir: string, + buildDir: string, +) => { + const browserContext = await createBrowserContext(userDataDir, buildDir) + const page = await browserContext.newPage() + + await page.bringToFront() + await page.goto("chrome://extensions") + await page.locator('[id="devMode"]').click() + const extensionId = await page + .locator('[id="extension-id"]') + .first() + .textContent() + .then((text) => text?.replace("ID: ", "")) + + const extensionURL = `chrome-extension://${extensionId}/index.html` + await page.goto(extensionURL) + await page.waitForTimeout(500) + + await page.emulateMedia({ reducedMotion: "reduce" }) + return { browserContext, extensionURL, page } +} + +function createExtension(label: string, upgrade: boolean = false) { + return async ({}, use: any, testInfo: TestInfo) => { + const userDataDir = `/tmp/test-user-data-${uuid()}` + let buildDir = config.distDir + if (upgrade) { + fs.copy(buildDir, config.migVersionDir) + buildDir = config.migVersionDir + } + const { browserContext, page, extensionURL } = + await initBrowserWithExtension(userDataDir, buildDir) + process.env.workerIndex = testInfo.workerIndex.toString() + const extension = new ExtensionPage(page, extensionURL, upgrade) + await closePages(browserContext) + browserCtx = browserContext + await use(extension) + + if (isKeepArtifacts(testInfo)) { + await saveHtml(testInfo, page, label) + await keepVideos(testInfo, page, label) + } + await browserContext.close() + } +} + +function getContext() { + return async ({}, use: any, _testInfo: TestInfo) => { + await use(browserCtx) + } +} + +const test = testBase.extend({ + extension: createExtension("extension"), + secondExtension: createExtension("secondExtension"), + thirdExtension: createExtension("thirdExtension"), + browserContext: getContext(), + upgradeExtension: createExtension("upgradeExtension", true), +}) + +export default test diff --git a/packages/e2e/src/utils/Clipboard.ts b/packages/e2e/src/utils/Clipboard.ts new file mode 100644 index 000000000..e85290e1a --- /dev/null +++ b/packages/e2e/src/utils/Clipboard.ts @@ -0,0 +1,46 @@ +import type { Page } from "@playwright/test" + +export default class Clipboard { + page: Page + private static clipboards: Map = new Map() + private readonly workerIndex: number + + constructor(page: Page) { + this.page = page + this.workerIndex = Number(process.env.workerIndex) + } + + async setClipboard(): Promise { + const text = String( + await this.page.evaluate(`navigator.clipboard.readText()`), + ) + Clipboard.clipboards.set(this.workerIndex, text) + } + + async setClipboardText(text: string): Promise { + Clipboard.clipboards.set(this.workerIndex, text) + } + + async getClipboard(): Promise { + return Clipboard.clipboards.get(this.workerIndex) || "" + } + + async paste(): Promise { + const content = Clipboard.clipboards.get(this.workerIndex) || "" + await this.page.evaluate( + (text) => navigator.clipboard.writeText(text), + content, + ) + const key = process.platform === "darwin" ? "Meta" : "Control" + await this.page.keyboard.press(`${key}+v`) + } + + async clear(): Promise { + Clipboard.clipboards.delete(this.workerIndex) + } + + // Optional: method to clear all clipboards + static clearAll(): void { + Clipboard.clipboards.clear() + } +} diff --git a/packages/e2e/shared/src/assets.ts b/packages/e2e/src/utils/assets.ts similarity index 87% rename from packages/e2e/shared/src/assets.ts rename to packages/e2e/src/utils/assets.ts index b038a0ab8..4b9865058 100644 --- a/packages/e2e/shared/src/assets.ts +++ b/packages/e2e/src/utils/assets.ts @@ -10,7 +10,6 @@ import { import commonConfig from "../config" import { expect } from "@playwright/test" import { logInfo, sleep } from "./common" -import { sendSlackMessage } from "./slack" const isEqualAddress = (a?: string, b?: string) => { try { @@ -24,14 +23,21 @@ const isEqualAddress = (a?: string, b?: string) => { return false } -export type TokenSymbol = "ETH" | "WBTC" | "STRK" | "SWAY" | "USDC" | "DAI" +export type TokenSymbol = + | "ETH" + | "WBTC" + | "STRK" + | "SWAY" + | "USDC" + | "DAI" + | "ádfas" export type TokenName = | "Ethereum" | "Wrapped BTC" | "Starknet" | "Standard Weighted Adalian Yield" - | "USD Coin" | "DAI" + | "USD Coin (Fake)" export type FeeTokens = "ETH" | "STRK" export interface AccountsToSetup { assets: { @@ -79,7 +85,11 @@ tokenAddresses.set("SWAY", { address: "0x0030058F19Ed447208015F6430F0102e8aB82D6c291566D7E73fE8e613c3D2ed", decimals: 18, }) - +tokenAddresses.set("USDC", { + name: "USD Coin (Fake)", + address: "0x07ab0b8855a61f480b4423c46c32fa7c553f0aac3531bbddaa282d86244f7a23", + decimals: 6, +}) export const getTokenInfo = (tkn: string) => { const tokenInfo = tokenAddresses.get(tkn) if (!tokenInfo) { @@ -182,6 +192,7 @@ const getTXData = async (txHash: string) => { } return { nodeUpdated, txData } } + export async function transferTokens( amount: number, to: string, @@ -239,7 +250,10 @@ export async function transferTokens( return null } -async function getBalance(accountAddress: string, token: TokenSymbol = "ETH") { +export async function getBalance( + accountAddress: string, + token: TokenSymbol = "ETH", +) { const tokenInfo = getTokenInfo(token) logInfo({ op: "getBalance", accountAddress, token, tokenInfo }) const balanceOfCall = { @@ -318,34 +332,3 @@ export function convertScientificToDecimal(num: number) { const exponent = String(num).split("e")[1] return Number(num).toFixed(Math.abs(Number(exponent))) } - -export async function notifyLowBalance() { - let msg: string = "" - for (const acc of commonConfig.senderAddrs!) { - for (const token of ["ETH", "STRK"]) { - const balance = await getBalance(acc, token as TokenSymbol) - if (parseFloat(balance) < 0.1) { - logInfo(`###### Low balance for ${acc} ${balance}`) - msg += "`" + acc + "` *" + balance + " " + token + "*\n" - } - } - } - if (msg) { - await sendSlackMessage(`*Low balance for:*\n\n${msg}\n`) - } -} - -export async function getBalances() { - let msg: string = "" - for (const acc of commonConfig.senderAddrs!) { - for (const token of ["ETH", "STRK"]) { - const balance = await getBalance(acc, token as TokenSymbol) - logInfo(`###### Balance: ${acc} ${balance}`) - msg += "`" + acc + "` *" + balance + " " + token + "*\n" - } - } - if (msg) { - logInfo(`\nBalance:\n\n${msg.replaceAll("`", "").replaceAll("*", "")}\n`) - await sendSlackMessage(`*Balance:*\n\n ${msg}\n`) - } -} diff --git a/packages/e2e/shared/src/common.ts b/packages/e2e/src/utils/common.ts similarity index 88% rename from packages/e2e/shared/src/common.ts rename to packages/e2e/src/utils/common.ts index fb0d5af72..2ce9d3409 100644 --- a/packages/e2e/shared/src/common.ts +++ b/packages/e2e/src/utils/common.ts @@ -1,4 +1,5 @@ import config from "../config" +import { v4 as uuid } from "uuid" export const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)) const app = "argentx" @@ -25,3 +26,5 @@ export const logInfo = (message: string | object) => { console.log(message) } } + +export const generateEmail = () => `e2e_2fa_${uuid()}@mail.com` diff --git a/packages/e2e/src/utils/download.ts b/packages/e2e/src/utils/download.ts new file mode 100644 index 000000000..7183cc456 --- /dev/null +++ b/packages/e2e/src/utils/download.ts @@ -0,0 +1,52 @@ +import axios from "axios" +import fs from "fs" +import { Stream, Readable } from "stream" +import { promisify } from "util" +import config from "../config" + +const pipeline = promisify(Stream.pipeline) + +function getFileIdByVersion(version: string) { + const versionData = JSON.parse(config.migVersions!) + const item = versionData.find( + (item: { version: string; fileId: string }) => item.version === version, + ) + return item ? item.fileId : null +} + +export async function downloadFile(version: string): Promise { + const fileId = getFileIdByVersion(version) + console.log("Downloading file:", fileId) + const driveUrl = `https://drive.google.com/uc?export=download&id=${fileId}` + const destPath = `${config.migDir}${version}.zip` + try { + const response = await axios({ + method: "GET", + url: driveUrl, + responseType: "stream", + }) + + if (response.status !== 200) { + throw new Error( + `Failed to download file: ${response.status} ${response.statusText}`, + ) + } + + const writer = fs.createWriteStream(destPath) + + if (!(response.data instanceof Readable)) { + throw new Error("Response data is not a readable stream") + } + + await pipeline(response.data, writer) + + console.log("File downloaded successfully") + } catch (error) { + if (error instanceof Error) { + console.error("Error downloading file:", error.message) + } else { + console.error("An unknown error occurred") + } + throw error + } +} diff --git a/packages/e2e/src/utils/getBranchVersion.sh b/packages/e2e/src/utils/getBranchVersion.sh new file mode 100755 index 000000000..1bc0956c2 --- /dev/null +++ b/packages/e2e/src/utils/getBranchVersion.sh @@ -0,0 +1,24 @@ +#!/bin/bash + +# Enable command printing +#set -x + +# Function to run a command and check its exit status +run_command() { + output=$("$@") + local status=$? + if [ $status -ne 0 ]; then + echo "Error: Command '$*' failed with exit status $status" >&2 + exit 1 + fi + echo "$output" +} + +# Extract the version +VERSION=$(run_command grep -m1 '"version":' ../extension/dist/manifest.json | awk -F: '{ print $2 }' | sed 's/[", ]//g') + +# Print the version +echo "$VERSION" + +# Return the version as the script's output +exit 0 \ No newline at end of file diff --git a/packages/e2e/src/utils/getBranchVersion.ts b/packages/e2e/src/utils/getBranchVersion.ts new file mode 100644 index 000000000..32a47a52e --- /dev/null +++ b/packages/e2e/src/utils/getBranchVersion.ts @@ -0,0 +1,22 @@ +import { execSync } from "child_process" +import * as path from "path" +import * as fs from "fs" + +export const getBranchVersion = (): string => { + const scriptPath = path.join(__dirname, "getBranchVersion.sh") + + try { + // Make the script executable + fs.chmodSync(scriptPath, "755") + + // Execute the script synchronously + const stdout = execSync(`bash ${scriptPath}`, { encoding: "utf8" }) + + console.log(`Version:${stdout}`) + + return stdout.trim() + } catch (error) { + console.error(`getVersion Error: ${error}`) + throw error + } +} diff --git a/packages/e2e/src/utils/global.teardown.ts b/packages/e2e/src/utils/global.teardown.ts new file mode 100644 index 000000000..294c45b42 --- /dev/null +++ b/packages/e2e/src/utils/global.teardown.ts @@ -0,0 +1,16 @@ +import * as fs from "fs" +import config from "../config" + +export default async function tearDown() { + console.time("tearDown") + try { + fs.readdirSync(config.artifactsDir) + .filter((f) => f.endsWith("webm")) + .forEach((fileToDelete) => { + fs.rmSync(`${config.artifactsDir}/${fileToDelete}`) + }) + } catch (error) { + console.error({ op: "tearDown", error }) + } + console.timeEnd("tearDown") +} diff --git a/packages/e2e/src/utils/index.ts b/packages/e2e/src/utils/index.ts new file mode 100644 index 000000000..ac3fd2033 --- /dev/null +++ b/packages/e2e/src/utils/index.ts @@ -0,0 +1,20 @@ +export { sleep, expireBESession, logInfo, generateEmail } from "./common" +export { default as Clipboard } from "./Clipboard" + +export { + TokenSymbol, + TokenName, + FeeTokens, + AccountsToSetup, + transferTokens, + getTokenInfo, + validateTx, + isScientific, + convertScientificToDecimal, + getBalance, +} from "./assets" + +export { unzip } from "./unzip" + +export { downloadFile } from "./download" +export { getBranchVersion as getVersion } from "./getBranchVersion" diff --git a/packages/e2e/src/utils/qaUtils.ts b/packages/e2e/src/utils/qaUtils.ts new file mode 100644 index 000000000..16794a7a0 --- /dev/null +++ b/packages/e2e/src/utils/qaUtils.ts @@ -0,0 +1,44 @@ +import config from "../config" + +const headers = { + "Content-Type": "application/json", + auth: config.qaUtilsAuthToken || "", +} + +export const requestFunds = async ( + account: string, + amount: number, + token: string = "ETH", +) => { + const requestOptions = { + method: "POST", + headers, + body: JSON.stringify({ + address: account, + token, + amount, + }), + } + const request = `${config.qaUtilsURL}/api/fundAccount` + + const response = await fetch(request, requestOptions) + if (response.status !== 200) { + console.error(await response.text()) + throw new Error(`Error funding account: ${account}`) + } + return response.status +} + +export const slackNotifyLowBalance = async () => { + const requestOptions = { + method: "GET", + headers, + } + const request = `${config.qaUtilsURL}/api/notifyLowBalance` + const response = await fetch(request, requestOptions) + if (response.status !== 200) { + console.error(await response.text()) + throw new Error(`Error notifying low balance`) + } + return response.status +} diff --git a/packages/e2e/src/utils/slackNotif.js b/packages/e2e/src/utils/slackNotif.js new file mode 100644 index 000000000..f461d50e7 --- /dev/null +++ b/packages/e2e/src/utils/slackNotif.js @@ -0,0 +1,38 @@ +//@TODO move this to qa utils package +if (!Boolean(process.env.CI)) { + const fs = require("fs") + const path = require("path") + const dotenv = require("dotenv") + + const envPath = path.resolve(__dirname, "../../.env") + if (fs.existsSync(envPath)) { + dotenv.config({ path: envPath }) + } +} +const headers = { + "Content-Type": "application/json", + auth: process.env.E2E_QA_UTILS_AUTH_TOKEN || "", +} + +const slackNotifyLowBalance = async () => { + const requestOptions = { + method: "GET", + headers, + } + const request = `${process.env.E2E_QA_UTILS_URL}/api/notifyLowBalance` + try { + const response = await fetch(request, requestOptions) + if (response.status !== 200) { + console.error(await response.text()) + throw new Error(`Error notifying low balance`) + } + return response.status + } catch (error) { + console.error("Failed to notify low balance:", error) + throw error + } +} + +slackNotifyLowBalance() + .then((status) => console.log("Notification sent, status:", status)) + .catch((error) => console.error("Failed to send notification:", error)) diff --git a/packages/e2e/src/utils/unzip.sh b/packages/e2e/src/utils/unzip.sh new file mode 100755 index 000000000..58881fe72 --- /dev/null +++ b/packages/e2e/src/utils/unzip.sh @@ -0,0 +1,58 @@ +#!/bin/bash + +# Enable command printing +#set -x + +# Function to run a command and check its exit status +run_command() { + "$@" + local status=$? + if [ $status -ne 0 ]; then + echo "Error: Command '$*' failed with exit status $status" + exit 1 + fi + return $status +} + +# Check if the correct number of arguments are provided +if [ $# -ne 2 ]; then + echo "Usage: $0 " + exit 1 +fi + +ZIP_FILE="$1" +OUTPUT_DIR="$2" + +# Check if ZIP_FILE exists +if [ ! -f "$ZIP_FILE" ]; then + echo "Error: ZIP file $ZIP_FILE does not exist" + exit 1 +fi + +BASE_NAME=$(basename "$ZIP_FILE" .zip) +echo "Removing directory: $OUTPUT_DIR/$BASE_NAME" +run_command rm -rf "$OUTPUT_DIR/$BASE_NAME" +run_command rm -rf "$OUTPUT_DIR/__MACOSX" + +# Create the output directory if it doesn't exist +run_command mkdir -p "$OUTPUT_DIR" + +# Check if bsdtar is installed, if not, install it +if ! command -v bsdtar &> /dev/null; then + echo "bsdtar not found. Installing libarchive-tools..." + run_command apt-get update + run_command apt-get install -y libarchive-tools +fi + +# Extract the zip file using bsdtar with verbose output +echo "Extracting $ZIP_FILE to $OUTPUT_DIR" +run_command bsdtar --no-xattrs -xf "$ZIP_FILE" -C "$OUTPUT_DIR" + +echo "Extraction completed" + +# Print the contents of the output directory for verification +#echo "Contents of $OUTPUT_DIR:" +#run_command ls -R "$OUTPUT_DIR" + +# Disable command printing +set +x \ No newline at end of file diff --git a/packages/e2e/src/utils/unzip.ts b/packages/e2e/src/utils/unzip.ts new file mode 100644 index 000000000..115745755 --- /dev/null +++ b/packages/e2e/src/utils/unzip.ts @@ -0,0 +1,36 @@ +import { exec } from "child_process" +import * as path from "path" +import { promisify } from "util" +import config from "../config" + +const execAsync = promisify(exec) + +export const unzip = async (version: string): Promise => { + const zipFilePath = path.join(config.migDir, `${version}.zip`) + const outputDir = path.join(config.migDir, version) + const scriptPath = path.join(__dirname, "unzip.sh") + + try { + console.log(`###### Unzipping ${version}.zip`) + + // Ensure the script is executable + await execAsync(`chmod +x ${scriptPath}`) + + // Execute the unzip script + const { stdout, stderr } = await execAsync( + `bash "${scriptPath}" "${zipFilePath}" "${outputDir}"`, + { maxBuffer: 1024 * 1024 * 10 }, // Increase buffer size to 10MB + ) + + console.log(`Unzip Output:\n${stdout}`) + + if (stderr) { + console.warn(`Unzip Warnings:\n${stderr}`) + } + } catch (error) { + console.error(`Error during unzip: ${error}`) + throw error + } + + return `${outputDir}/dist` +} diff --git a/packages/e2e/until-failure b/packages/e2e/until-failure new file mode 100755 index 000000000..d0bc639a4 --- /dev/null +++ b/packages/e2e/until-failure @@ -0,0 +1,9 @@ +#!/bin/bash +# +# Example usage: +# +# # Repeats test until it fails: +# ./until-failure pnpm playwright test --config=./extension src/specs/accountSettings.spec.ts:95 +# + +while "$@"; do :; done diff --git a/packages/eslint-plugin-local/package.json b/packages/eslint-plugin-local/package.json index b9f1c122b..bf280870c 100644 --- a/packages/eslint-plugin-local/package.json +++ b/packages/eslint-plugin-local/package.json @@ -10,7 +10,7 @@ "types": "./dist/index.d.ts", "devDependencies": { "@tsconfig/node20": "^20.1.2", - "@types/eslint": "^8.7.0", + "@types/eslint": "^9.0.0", "@typescript-eslint/experimental-utils": "^5.59.7", "eslint": "^8.7.0", "minimatch": "^10.0.0", diff --git a/packages/extension/.env.example b/packages/extension/.env.example index a28bf6843..13fdd5c50 100644 --- a/packages/extension/.env.example +++ b/packages/extension/.env.example @@ -1,11 +1,9 @@ -SEGMENT_WRITE_KEY= SENTRY_AUTH_TOKEN= RAMP_API_KEY= ARGENT_API_BASE_URL= ARGENT_X_STATUS_URL= ARGENT_X_NEWS_URL= ARGENT_X_ENVIRONMENT= -ARGENT_TESTNET_RPC_URL= NEW_CAIRO_0_ENABLED= #PRESET_SEED= @@ -18,21 +16,21 @@ NEW_CAIRO_0_ENABLED= #FEATURE_PRIVACY_SETTINGS= #FEATURE_EXPERIMENTAL_SETTINGS= #FEATURE_BETA_FEATURES= -#ARGENT_SHIELD_NETWORK_ID= -#FEATURE_VERIFIED_DAPPS= +# #FEATURE_MULTISIG= #MULTICALL_MAX_BATCH_SIZE= #FEATURE_HIDE_DEPRECATED_ACCOUNTS= -#FEATURE_ACTIVITY_V2= - +#FEATURE_STAKING= +#FEATURE_DEFI_DECOMPOSITION= # in seconds REFRESH_INTERVAL_FAST=20 REFRESH_INTERVAL_MEDIUM=60 REFRESH_INTERVAL_SLOW=300 REFRESH_INTERVAL_VERY_SLOW=86400 -ARGENT_HEALTHCHECK_BASE_URL=https://healthcheck.hydrogen.argent47.net #REACT_DEVTOOLS=true TOPPER_PEM_KEY= #USE_INDEXDB_LOGGER=true +# ENABLE_TOKEN_DETAILS=true +# MIN_LEDGER_APP_VERSION= \ No newline at end of file diff --git a/packages/extension/.eslintrc.base.js b/packages/extension/.eslintrc.base.js index 5584fbdfb..6d379509d 100644 --- a/packages/extension/.eslintrc.base.js +++ b/packages/extension/.eslintrc.base.js @@ -52,5 +52,7 @@ module.exports = { "@typescript-eslint/no-misused-promises": "warn", "@typescript-eslint/no-floating-promises": "warn", "no-restricted-globals": ["error", "origin"], // Error on use of global 'origin' which defaults to current window url + "@typescript-eslint/consistent-type-exports": "error", + "@typescript-eslint/consistent-type-imports": "error", }, } diff --git a/packages/extension/.eslintrc.js b/packages/extension/.eslintrc.js index 9eb4f35e8..6462d2779 100644 --- a/packages/extension/.eslintrc.js +++ b/packages/extension/.eslintrc.js @@ -11,6 +11,18 @@ module.exports = { message: "Please use lodash-es instead.", allowTypeImports: true, }, + { + name: "lodash-es", + importNames: ["memoize"], + message: "Please use memoizee instead of lodash memoize.", + }, + ], + patterns: [ + { + group: ["**/ampli", "**/ampli/**"], + importNames: ["ampli"], + message: "Please import 'ampli' from 'shared/analytics' instead.", + }, ], }, ], diff --git a/packages/extension/CHANGELOG.md b/packages/extension/CHANGELOG.md index 64d7c8e27..dc278cc1a 100644 --- a/packages/extension/CHANGELOG.md +++ b/packages/extension/CHANGELOG.md @@ -1,5 +1,71 @@ # @argent-x/extension +## 6.20.5 + +### Patch Changes + +- c293b9f: Release + +## 6.20.4 + +### Patch Changes + +- 543923e: Release + +## 6.20.3 + +### Patch Changes + +- 8fb28bc: Release + +## 6.20.2 + +### Patch Changes + +- 902206b: Release + +## 6.20.1 + +### Patch Changes + +- 54d70bc: Release + +## 6.20.0 + +### Minor Changes + +- 9612010: Release + +## 6.19.4 + +### Patch Changes + +- 7f6be66: Release + +## 6.19.3 + +### Patch Changes + +- 85fbada: Release + +## 6.19.2 + +### Patch Changes + +- 426beca: Release + +## 6.19.1 + +### Patch Changes + +- c8aca4b: Release + +## 6.19.0 + +### Minor Changes + +- faaf8a2: Release + ## 6.18.5 ### Patch Changes diff --git a/packages/extension/ampli.json b/packages/extension/ampli.json index 265bd701a..5eb3a8339 100644 --- a/packages/extension/ampli.json +++ b/packages/extension/ampli.json @@ -4,8 +4,8 @@ "WorkspaceId": "d898dc13-a4ff-4261-bcbd-66d96e65841a", "SourceId": "c0973ae9-a369-4593-943f-1129ee98144d", "Branch": "main", - "Version": "41.0.0", - "VersionId": "b19d7e07-65a0-450d-9b22-cc8811e6f0a4", + "Version": "61.0.0", + "VersionId": "73703ce7-e97a-4b27-84da-d72d0085c68e", "Runtime": "browser:typescript-ampli-v2", "Platform": "Browser", "Language": "TypeScript", diff --git a/packages/extension/build/getSafeGetCommitHash.ts b/packages/extension/build/getSafeGetCommitHash.ts index 55a28b6ab..cbedb96f5 100644 --- a/packages/extension/build/getSafeGetCommitHash.ts +++ b/packages/extension/build/getSafeGetCommitHash.ts @@ -9,7 +9,7 @@ export function getSafeGetCommitHash() { try { const hash = execSync("git rev-parse HEAD") return hash.toString().trim() - } catch (e) { + } catch { return "unknown" } } diff --git a/packages/extension/build/htmlWebpackInlineStylePlugin.ts b/packages/extension/build/htmlWebpackInlineStylePlugin.ts new file mode 100644 index 000000000..7bbffe3f2 --- /dev/null +++ b/packages/extension/build/htmlWebpackInlineStylePlugin.ts @@ -0,0 +1,52 @@ +import type { Compiler, Compilation } from "webpack" +import HtmlWebpackPlugin from "html-webpack-plugin" +import CleanCSS from "clean-css" + +interface HtmlPluginData { + html: string + outputName: string + plugin: HtmlWebpackPlugin +} + +/** + * InlineStylePlugin + * + * This webpack plugin works in conjunction with HtmlWebpackPlugin to minify inline CSS styles + * within ` + }, + ) + cb(null, data) + }, + ) + }, + ) + } +} diff --git a/packages/extension/build/reactDevTools.ts b/packages/extension/build/reactDevTools.ts index dfca2e25a..b33ec636b 100644 --- a/packages/extension/build/reactDevTools.ts +++ b/packages/extension/build/reactDevTools.ts @@ -9,7 +9,7 @@ function checkReactDevTools() { try { execSync(`${command} react-devtools`, { stdio: "ignore" }) return true - } catch (error) { + } catch { console.error( "❌ react-devtools is not installed - run `npm install -g react-devtools`", ) diff --git a/packages/extension/build/transformManifestJson.ts b/packages/extension/build/transformManifestJson.ts index ee23f21da..20edfc335 100644 --- a/packages/extension/build/transformManifestJson.ts +++ b/packages/extension/build/transformManifestJson.ts @@ -1,4 +1,4 @@ -import CopyPlugin from "copy-webpack-plugin" +import type CopyPlugin from "copy-webpack-plugin" import { getReleaseTrack } from "./getReleaseTrack" import { HOT_RELOAD_PORT } from "../src/shared/utils/dev" diff --git a/packages/extension/manifest/v2.json b/packages/extension/manifest/v2.json index 3e7b6c9e9..f2efba6bd 100644 --- a/packages/extension/manifest/v2.json +++ b/packages/extension/manifest/v2.json @@ -2,7 +2,7 @@ "$schema": "https://json.schemastore.org/chrome-manifest.json", "name": "Argent X - Starknet Wallet", "description": "7 out of 10 Starknet users choose Argent X as their Starknet wallet. Join 2m+ Argent users now.", - "version": "5.18.5", + "version": "5.20.5", "manifest_version": 2, "browser_action": { "default_icon": { diff --git a/packages/extension/manifest/v3.json b/packages/extension/manifest/v3.json index ab030c707..446867b0e 100644 --- a/packages/extension/manifest/v3.json +++ b/packages/extension/manifest/v3.json @@ -2,7 +2,7 @@ "$schema": "https://json.schemastore.org/chrome-manifest.json", "name": "Argent X - Starknet Wallet", "description": "7 out of 10 Starknet users choose Argent X as their Starknet wallet. Join 2m+ Argent users now.", - "version": "5.18.5", + "version": "5.20.5", "manifest_version": 3, "action": { "default_icon": { diff --git a/packages/extension/package.json b/packages/extension/package.json index c525abb02..8990c9580 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -1,6 +1,6 @@ { "name": "@argent-x/extension", - "version": "6.18.5", + "version": "6.20.5", "main": "index.js", "private": true, "license": "MIT", @@ -13,32 +13,35 @@ "@testing-library/react": "^16.0.0", "@testing-library/user-event": "^14.5.2", "@types/async-retry": "^1.4.5", - "@types/chrome": "^0.0.269", + "@types/chrome": "^0.0.280", + "@types/clean-css": "^4.2.11", "@types/copy-webpack-plugin": "^10.1.0", "@types/dotenv-webpack": "^7.0.4", "@types/fs-extra": "^11.0.1", + "@types/glob": "^8.1.0", "@types/lodash-es": "^4.17.6", + "@types/memoizee": "^0.4.11", "@types/object-hash": "^3.0.2", "@types/react": "^18.0.0", "@types/react-copy-to-clipboard": "5.0.7", "@types/react-dom": "^18.0.0", "@types/react-measure": "^2.0.8", "@types/semver": "^7.3.10", - "@types/styled-components": "^5.1.25", "@types/url-join": "^4.0.1", "@types/w3c-web-hid": "^1.0.2", "@types/webpack": "^5.28.5", "@types/webpack-dev-server": "^4.7.2", "@types/ws": "^8.5.3", - "@typescript-eslint/eslint-plugin": "^7.0.0", - "@typescript-eslint/parser": "^7.0.0", + "@typescript-eslint/eslint-plugin": "^8.0.0", + "@typescript-eslint/parser": "^8.0.0", "@vitejs/plugin-react-swc": "^3.3.1", - "@vitest/browser": "2.0.4", + "@vitest/browser": "2.1.4", "@vitest/coverage-istanbul": "^2.0.0", - "@vitest/coverage-v8": "2.0.4", - "@vitest/ui": "2.0.4", - "chokidar": "^3.5.2", - "concurrently": "^8.0.1", + "@vitest/coverage-v8": "2.1.4", + "@vitest/ui": "2.1.4", + "chokidar": "^4.0.0", + "clean-css": "^5.3.3", + "concurrently": "^9.0.0", "copy-webpack-plugin": "^12.0.0", "cross-fetch": "^4.0.0", "dotenv": "^16.1.4", @@ -46,28 +49,27 @@ "esbuild-loader": "^4.0.0", "eslint": "^8.7.0", "eslint-plugin-react": "^7.28.0", - "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-react-hooks": "^5.0.0", "fake-indexeddb": "^6.0.0", "fetch-intercept": "^2.4.0", "file-loader": "^6.2.0", "fork-ts-checker-webpack-plugin": "^9.0.0", "fs-extra": "^11.1.1", - "happy-dom": "^14.0.0", + "happy-dom": "^15.0.0", "html-webpack-plugin": "^5.5.0", "isomorphic-fetch": "^3.0.0", "jotai-devtools": "^0.10.0", "minimatch": "^10.0.0", - "msw": "^2.0.0", + "msw": "^2.4.1", "raw-loader": "^4.0.2", "source-map-loader": "^5.0.0", "ts-node": "^10.9.1", "tsx": "^4.7.1", "type-fest": "^4.0.0", - "typescript": "^5.0.4", - "typescript-styled-plugin": "^0.18.2", + "typescript": "^5.5.3", "url-loader": "^4.1.1", "vite": "^5.0.0", - "vitest": "2.0.4", + "vitest": "2.1.4", "wait-for-expect": "^3.0.2", "webpack": "^5.88.0", "webpack-cli": "^5.1.1", @@ -75,7 +77,6 @@ }, "scripts": { "build": "NODE_ENV=production webpack", - "build:sourcemaps": "GEN_SOURCE_MAPS=true pnpm build", "start": "webpack", "dev": "concurrently -k -r \"webpack --color --watch\" \"pnpm run dev:tools\"", "dev:ui": "SHOW_DEV_UI=true pnpm dev", @@ -85,33 +86,31 @@ "test:watch": "vitest", "test:ci": "vitest run --coverage", "export": "tsx ./scripts/export.ts", - "gen:theme-typings": "chakra-cli tokens ./node_modules/@argent/x-ui/dist/index.mjs", - "setup": "pnpm gen:theme-typings" + "gen:theme-typings": "chakra-cli tokens ./node_modules/@argent/x-ui/dist/index.cjs --out ../../node_modules/@chakra-ui/styled-system/dist/theming.types.d.ts", + "setup": "pnpm gen:theme-typings", + "check-bundle-size": "tsx ./scripts/check-bundle-size.ts" }, "dependencies": { "@amplitude/analytics-browser": "^2.4.1", "@argent/stack-router": "^6.3.1", "@argent/x-guardian": "^1.1.0", "@argent/x-multicall": "^7.1.0", - "@argent/x-sessions": "^6.5.2", - "@argent/x-shared": "^1.33.1", - "@argent/x-ui": "^1.42.4", - "@argent/x-window": "^6.4.2", + "@argent/x-ui": "^1.80.5", + "@argent/x-shared": "^1.48.3", + "@argent/x-window": "^1.0.2", "@chakra-ui/cli": "^2.4.1", "@chakra-ui/icons": "^2.0.15", "@chakra-ui/react": "^2.8.2", - "@emotion/react": "^11.11.3", - "@emotion/styled": "^11.11.0", + "@emotion/react": "11.13.0", + "@emotion/styled": "11.13.0", "@ethersproject/bignumber": "^5.7.0", "@extend-chrome/messages": "^1.2.2", - "@google/model-viewer": "^3.0.0", + "@google/model-viewer": "^4.0.0", "@hookform/resolvers": "^3.4.2", - "@ledgerhq/hw-app-starknet": "^2.1.2", - "@ledgerhq/hw-transport": "^6.30.5", + "@ledgerhq/hw-app-starknet": "2.4.0", + "@ledgerhq/hw-transport": "^6.31.2", "@ledgerhq/hw-transport-webhid": "^6.28.5", - "@mui/icons-material": "^5.3.1", - "@mui/material": "^5.1.0", - "@mui/styled-engine-sc": "^5.10.3", + "@noble/ciphers": "^1.0.0", "@noble/curves": "^1.0.0", "@noble/hashes": "^1.3.1", "@rive-app/react-canvas": "^4.9.5", @@ -119,12 +118,10 @@ "@scure/bip32": "^1.3.1", "@scure/bip39": "^1.2.1", "@sentry/browser": "^8.19.0", - "@sentry/react": "^8.19.0", - "@tippyjs/react": "^4.2.6", + "@sentry/webpack-plugin": "^2.22.2", "@trpc/client": "^10.28.0", "@trpc/server": "^10.31.0", "@types/ua-parser-js": "^0.7.39", - "@vitest/coverage-istanbul": "^2.0.0", "@zxcvbn-ts/core": "^3.0.4", "@zxcvbn-ts/language-common": "^3.0.0", "@zxcvbn-ts/language-en": "^3.0.0", @@ -133,16 +130,20 @@ "dexie": "^4.0.7", "dexie-logger": "^1.2.6", "dexie-react-hooks": "^1.1.1", + "embla-carousel": "^8.3.0", + "embla-carousel-react": "^8.3.0", "emittery": "^1.0.1", "eslint-config-prettier": "^9.0.0", - "eslint-plugin-prettier": "^5.0.0", "ethers": "^6.8.0", + "fetch-cookie": "^3.0.1", "fflate": "^0.8.2", "framer-motion": "^11.0.5", "history": "^5.3.0", "jose": "^5.0.0", "jotai": "^2.0.4", + "lightweight-charts": "^4.2.0", "lodash-es": "^4.17.21", + "memoizee": "^0.4.17", "micro-starknet": "^0.2.3", "nanoid": "^5.0.0", "object-hash": "^3.0.0", @@ -154,26 +155,20 @@ "react-dropzone": "^14.0.0", "react-hook-form": "^7.51.5", "react-measure": "^2.5.2", - "react-router-dom": "^6.0.1", + "react-router-dom": "6.27.0", "react-select": "^5.4.0", - "react-textarea-autosize": "^8.3.4", "react-virtuoso": "^4.7.2", "semver": "^7.5.2", "sonner": "^1.5.0", "starknet": "6.11.0", "starknet4": "npm:starknet@4.22.0", - "starknet4-deprecated": "npm:starknet@4.4.0", "starknet5": "npm:starknet@5.29.0", - "starknetkit": "^2.2.10", - "styled-components": "^5.3.5", - "styled-normalize": "^8.0.7", "superjson": "^2.2.1", "swr": "^1.3.0", "trpc-browser": "^1.4.2", "ua-parser-js": "^1.0.37", "url-join": "^5.0.0", "webextension-polyfill": "^0.12.0", - "yup": "^1.0.0-beta.4", "zod": "^3.23.8", "zustand": "^4.4.4" } diff --git a/packages/extension/scripts/check-bundle-size.ts b/packages/extension/scripts/check-bundle-size.ts new file mode 100644 index 000000000..4379e697a --- /dev/null +++ b/packages/extension/scripts/check-bundle-size.ts @@ -0,0 +1,64 @@ +import fs from "fs" +import path from "path" +import glob from "glob" + +/** max allowed file size for Firefox is 4MB */ +const MAX_SIZE_MB = 4 +const MAX_SIZE_BYTES = MAX_SIZE_MB * 1024 * 1024 +const SOURCE_DIR = path.resolve(__dirname, "../dist") +const GLOB_PATTERN = `**/*.!(*(*.)map)` + +function checkFileSizes() { + const files = glob.sync(path.join(SOURCE_DIR, GLOB_PATTERN)).sort() + let exceeded = false + + files.forEach((file) => { + const stats = fs.statSync(file) + const sizeInBytes = stats.size + const formattedSize = formatSize(sizeInBytes) + const relativePath = path.relative(SOURCE_DIR, file) + + if (sizeInBytes > MAX_SIZE_BYTES) { + console.log(`\x1b[31mFAIL\x1b[0m ${relativePath} (${formattedSize})`) + exceeded = true + } else { + console.log(`\x1b[32mPASS\x1b[0m ${relativePath} (${formattedSize})`) + } + }) + + console.log("") + + if (exceeded) { + console.log( + `\x1b[31mFAIL\x1b[0m Some files exceed the ${MAX_SIZE_MB}MB size limit:`, + ) + files.forEach((file) => { + const stats = fs.statSync(file) + const sizeInBytes = stats.size + const formattedSize = formatSize(sizeInBytes) + if (sizeInBytes > MAX_SIZE_BYTES) { + const relativePath = path.relative(SOURCE_DIR, file) + console.log(` ${relativePath} (${formattedSize})`) + } + }) + process.exit(1) + } else { + console.log(`\x1b[32mPASS\x1b[0m All files are ${MAX_SIZE_MB}MB or smaller`) + process.exit(0) + } +} + +function formatSize(bytes: number): string { + const units = ["B", "KB", "MB", "GB"] + let size = bytes + let unitIndex = 0 + + while (size >= 1024 && unitIndex < units.length - 1) { + size /= 1024 + unitIndex++ + } + + return `${size.toFixed(2)} ${units[unitIndex]}` +} + +checkFileSizes() diff --git a/packages/extension/scripts/export.ts b/packages/extension/scripts/export.ts index 2994b99d6..6120b6213 100644 --- a/packages/extension/scripts/export.ts +++ b/packages/extension/scripts/export.ts @@ -103,6 +103,6 @@ async function exportForEndUserBuild() { console.log("✅ Buildable source and Readme exported to", destination) } -;(async () => { +void (async () => { await exportForEndUserBuild() })() diff --git a/packages/extension/src/ampli/index.ts b/packages/extension/src/ampli/index.ts index ff6a6d327..d59a5a981 100644 --- a/packages/extension/src/ampli/index.ts +++ b/packages/extension/src/ampli/index.ts @@ -8,7 +8,7 @@ * To update run 'ampli pull argent-x' * * Required dependencies: @amplitude/analytics-browser@^1.3.0 - * Tracking Plan Version: 41 + * Tracking Plan Version: 61 * Build: 1.0.0 * Runtime: browser:typescript-ampli-v2 * @@ -30,10 +30,10 @@ export const ApiKey: Record = { */ export const DefaultConfiguration: BrowserOptions = { plan: { - version: "41", + version: "61", branch: "main", source: "argent-x", - versionId: "b19d7e07-65a0-450d-9b22-cc8811e6f0a4", + versionId: "73703ce7-e97a-4b27-84da-d72d0085c68e", }, ...{ ingestionMetadata: { @@ -65,6 +65,51 @@ export type LoadOptions = | LoadOptionsWithClientInstance export interface IdentifyProperties { + /** + * Count the number of ledger user accounts for the user + * + * | Rule | Value | + * |---|---| + * | Type | integer | + * | Min Value | 0 | + */ + "ArgentX Ledger Accounts Count"?: number + /** + * Count the number of multisig accounts for the user + * + * | Rule | Value | + * |---|---| + * | Type | integer | + * | Min Value | 0 | + */ + "ArgentX Multisig Accounts Count"?: number + /** + * Count the number of smart accounts for the user + * + * | Rule | Value | + * |---|---| + * | Type | integer | + * | Min Value | 0 | + */ + "ArgentX Smart Accounts Count"?: number + /** + * Count the number of standard accounts for the user + * + * | Rule | Value | + * |---|---| + * | Type | integer | + * | Min Value | 0 | + */ + "ArgentX Standard Accounts Count"?: number + /** + * Count the number of testnet accounts for the user + * + * | Rule | Value | + * |---|---| + * | Type | integer | + * | Min Value | 0 | + */ + "ArgentX Testnet Accounts Count"?: number /** * | Rule | Value | * |---|---| @@ -132,22 +177,12 @@ export interface AccountCreatedProperties { * * | Rule | Value | * |---|---| - * | Enum Values | browser extension, mobile, web | + * | Enum Values | browser extension, mobile, web, telegram | */ - "wallet platform": "browser extension" | "mobile" | "web" + "wallet platform": "browser extension" | "mobile" | "web" | "telegram" } export interface AccountDeployedProperties { - /** - * Used for Argent Mobile only to handle some legacy regarding multiple blockchain networks/chains. - * - * Do not use this to indicate testnet. - * - * | Rule | Value | - * |---|---| - * | Enum Values | ethereum, zksync lite, zksync era, starknet | - */ - "account chain"?: "ethereum" | "zksync lite" | "zksync era" | "starknet" /** * Account index refers to how each account is indexed on the wallet from a technical perspective. * @@ -158,14 +193,6 @@ export interface AccountDeployedProperties { * | Type | number | */ "account index"?: number - /** - * Used by Argent Mobile to indicate AX Import status of the account. - * - * | Rule | Value | - * |---|---| - * | Enum Values | no_key, has_key | - */ - "account key status"?: "no_key" | "has_key" /** * Used to categorise into the 3 account types we offer today: Standard, Smart, Multisig. * @@ -187,9 +214,9 @@ export interface AccountDeployedProperties { * * | Rule | Value | * |---|---| - * | Enum Values | browser extension, mobile, web | + * | Enum Values | browser extension, mobile, web, telegram | */ - "wallet platform": "browser extension" | "mobile" | "web" + "wallet platform": "browser extension" | "mobile" | "web" | "telegram" } export interface ActivityTabClickedProperties { @@ -202,9 +229,9 @@ export interface ActivityTabClickedProperties { * * | Rule | Value | * |---|---| - * | Enum Values | browser extension, mobile, web | + * | Enum Values | browser extension, mobile, web, telegram | */ - "wallet platform": "browser extension" | "mobile" | "web" + "wallet platform": "browser extension" | "mobile" | "web" | "telegram" } export interface ApplicationOpenedProperties { @@ -217,13 +244,13 @@ export interface ApplicationOpenedProperties { * * | Rule | Value | * |---|---| - * | Enum Values | browser extension, mobile, web | + * | Enum Values | browser extension, mobile, web, telegram | */ - "wallet platform": "browser extension" | "mobile" | "web" + "wallet platform": "browser extension" | "mobile" | "web" | "telegram" } export interface BlockerExplorerChangedProperties { - provider: string + provider?: string /** * This is a REQUIRED property, and it must be fired for ALL events on this project. * @@ -233,9 +260,9 @@ export interface BlockerExplorerChangedProperties { * * | Rule | Value | * |---|---| - * | Enum Values | browser extension, mobile, web | + * | Enum Values | browser extension, mobile, web, telegram | */ - "wallet platform": "browser extension" | "mobile" | "web" + "wallet platform": "browser extension" | "mobile" | "web" | "telegram" } export interface CustomTokenAddedProperties { @@ -248,13 +275,22 @@ export interface CustomTokenAddedProperties { * * | Rule | Value | * |---|---| - * | Enum Values | browser extension, mobile, web | + * | Enum Values | browser extension, mobile, web, telegram | */ - "wallet platform": "browser extension" | "mobile" | "web" + "wallet platform": "browser extension" | "mobile" | "web" | "telegram" } export interface DappPreauthorizedProperties { + /** + * Describes host part of an URL e.g. app.ekubo.org for + */ host?: string + /** + * **Mobile:** + * + * Describes if the dapp preauthorisation includes starknet as one of the requested chains. Required since WC can request reauthorization for multiple chains, not only Starknet. Always true for in-app browser. + */ + "preauthorisation has starknet"?: boolean /** * | Rule | Value | * |---|---| @@ -276,9 +312,9 @@ export interface DappPreauthorizedProperties { * * | Rule | Value | * |---|---| - * | Enum Values | browser extension, mobile, web | + * | Enum Values | browser extension, mobile, web, telegram | */ - "wallet platform": "browser extension" | "mobile" | "web" + "wallet platform": "browser extension" | "mobile" | "web" | "telegram" } export interface DiscoverTabClickedProperties { @@ -291,9 +327,9 @@ export interface DiscoverTabClickedProperties { * * | Rule | Value | * |---|---| - * | Enum Values | browser extension, mobile, web | + * | Enum Values | browser extension, mobile, web, telegram | */ - "wallet platform": "browser extension" | "mobile" | "web" + "wallet platform": "browser extension" | "mobile" | "web" | "telegram" } export interface FeedPostClickedProperties { @@ -322,9 +358,9 @@ export interface FeedPostClickedProperties { * * | Rule | Value | * |---|---| - * | Enum Values | browser extension, mobile, web | + * | Enum Values | browser extension, mobile, web, telegram | */ - "wallet platform": "browser extension" | "mobile" | "web" + "wallet platform": "browser extension" | "mobile" | "web" | "telegram" } export interface HomeTabClickedProperties { @@ -337,9 +373,9 @@ export interface HomeTabClickedProperties { * * | Rule | Value | * |---|---| - * | Enum Values | browser extension, mobile, web | + * | Enum Values | browser extension, mobile, web, telegram | */ - "wallet platform": "browser extension" | "mobile" | "web" + "wallet platform": "browser extension" | "mobile" | "web" | "telegram" } export interface LedgerUserAccountAddedProperties { @@ -363,13 +399,16 @@ export interface LedgerUserAccountAddedProperties { * * | Rule | Value | * |---|---| - * | Enum Values | browser extension, mobile, web | + * | Enum Values | browser extension, mobile, web, telegram | */ - "wallet platform": "browser extension" | "mobile" | "web" + "wallet platform": "browser extension" | "mobile" | "web" | "telegram" } export interface MessageSignedProperties { "from outside"?: boolean + /** + * Describes host part of an URL e.g. app.ekubo.org for + */ host?: string /** * This is a REQUIRED property, and it must be fired for ALL events on this project. @@ -380,9 +419,9 @@ export interface MessageSignedProperties { * * | Rule | Value | * |---|---| - * | Enum Values | browser extension, mobile, web | + * | Enum Values | browser extension, mobile, web, telegram | */ - "wallet platform": "browser extension" | "mobile" | "web" + "wallet platform": "browser extension" | "mobile" | "web" | "telegram" } export interface NftMarketplaceChangedProperties { @@ -396,9 +435,9 @@ export interface NftMarketplaceChangedProperties { * * | Rule | Value | * |---|---| - * | Enum Values | browser extension, mobile, web | + * | Enum Values | browser extension, mobile, web, telegram | */ - "wallet platform": "browser extension" | "mobile" | "web" + "wallet platform": "browser extension" | "mobile" | "web" | "telegram" } export interface OnboardingAccountTypeSelectedProperties { @@ -432,9 +471,9 @@ export interface OnboardingAccountTypeSelectedProperties { * * | Rule | Value | * |---|---| - * | Enum Values | browser extension, mobile, web | + * | Enum Values | browser extension, mobile, web, telegram | */ - "wallet platform": "browser extension" | "mobile" | "web" + "wallet platform": "browser extension" | "mobile" | "web" | "telegram" } export interface OnboardingAnalyticsDecidedProperties { @@ -457,9 +496,9 @@ export interface OnboardingAnalyticsDecidedProperties { * * | Rule | Value | * |---|---| - * | Enum Values | browser extension, mobile, web | + * | Enum Values | browser extension, mobile, web, telegram | */ - "wallet platform": "browser extension" | "mobile" | "web" + "wallet platform": "browser extension" | "mobile" | "web" | "telegram" } export interface OnboardingCompletedProperties { @@ -493,9 +532,9 @@ export interface OnboardingCompletedProperties { * * | Rule | Value | * |---|---| - * | Enum Values | browser extension, mobile, web | + * | Enum Values | browser extension, mobile, web, telegram | */ - "wallet platform": "browser extension" | "mobile" | "web" + "wallet platform": "browser extension" | "mobile" | "web" | "telegram" } export interface OnboardingEmailEnteredProperties { @@ -517,9 +556,9 @@ export interface OnboardingEmailEnteredProperties { * * | Rule | Value | * |---|---| - * | Enum Values | browser extension, mobile, web | + * | Enum Values | browser extension, mobile, web, telegram | */ - "wallet platform": "browser extension" | "mobile" | "web" + "wallet platform": "browser extension" | "mobile" | "web" | "telegram" } export interface OnboardingEmailFlowAbortedProperties { @@ -541,9 +580,9 @@ export interface OnboardingEmailFlowAbortedProperties { * * | Rule | Value | * |---|---| - * | Enum Values | browser extension, mobile, web | + * | Enum Values | browser extension, mobile, web, telegram | */ - "wallet platform": "browser extension" | "mobile" | "web" + "wallet platform": "browser extension" | "mobile" | "web" | "telegram" } export interface OnboardingPasswordSetProperties { @@ -565,9 +604,9 @@ export interface OnboardingPasswordSetProperties { * * | Rule | Value | * |---|---| - * | Enum Values | browser extension, mobile, web | + * | Enum Values | browser extension, mobile, web, telegram | */ - "wallet platform": "browser extension" | "mobile" | "web" + "wallet platform": "browser extension" | "mobile" | "web" | "telegram" } export interface OnboardingStartedProperties { @@ -589,9 +628,9 @@ export interface OnboardingStartedProperties { * * | Rule | Value | * |---|---| - * | Enum Values | browser extension, mobile, web | + * | Enum Values | browser extension, mobile, web, telegram | */ - "wallet platform": "browser extension" | "mobile" | "web" + "wallet platform": "browser extension" | "mobile" | "web" | "telegram" } export interface OnboardingVerificationCodeAcceptedProperties { @@ -613,9 +652,9 @@ export interface OnboardingVerificationCodeAcceptedProperties { * * | Rule | Value | * |---|---| - * | Enum Values | browser extension, mobile, web | + * | Enum Values | browser extension, mobile, web, telegram | */ - "wallet platform": "browser extension" | "mobile" | "web" + "wallet platform": "browser extension" | "mobile" | "web" | "telegram" } export interface OnboardingVerificationCodeRejectedProperties { @@ -637,9 +676,9 @@ export interface OnboardingVerificationCodeRejectedProperties { * * | Rule | Value | * |---|---| - * | Enum Values | browser extension, mobile, web | + * | Enum Values | browser extension, mobile, web, telegram | */ - "wallet platform": "browser extension" | "mobile" | "web" + "wallet platform": "browser extension" | "mobile" | "web" | "telegram" } export interface OnboardingVerificationCodeResentProperties { @@ -661,9 +700,9 @@ export interface OnboardingVerificationCodeResentProperties { * * | Rule | Value | * |---|---| - * | Enum Values | browser extension, mobile, web | + * | Enum Values | browser extension, mobile, web, telegram | */ - "wallet platform": "browser extension" | "mobile" | "web" + "wallet platform": "browser extension" | "mobile" | "web" | "telegram" } export interface SwapQuoteFailedProperties { @@ -688,9 +727,9 @@ export interface SwapQuoteFailedProperties { * * | Rule | Value | * |---|---| - * | Enum Values | browser extension, mobile, web | + * | Enum Values | browser extension, mobile, web, telegram | */ - "wallet platform": "browser extension" | "mobile" | "web" + "wallet platform": "browser extension" | "mobile" | "web" | "telegram" } export interface SwapTabClickedProperties { @@ -703,12 +742,44 @@ export interface SwapTabClickedProperties { * * | Rule | Value | * |---|---| - * | Enum Values | browser extension, mobile, web | + * | Enum Values | browser extension, mobile, web, telegram | + */ + "wallet platform": "browser extension" | "mobile" | "web" | "telegram" +} + +export interface TestnetAccountImportCompletedProperties { + /** + * This can be used to determine whether the account is Braavos, Argent, Metamask, OpenZeplin etc. */ - "wallet platform": "browser extension" | "mobile" | "web" + "imported account class hash"?: string +} + +export interface TestnetAccountImportFailedProperties { + /** + * This can be used to determine whether the account is Braavos, Argent, Metamask, OpenZeplin etc. + */ + "imported account class hash"?: string + /** + * Used for Argent Mobile (AX import) + * Used for Argent X (PK import) + * + * | Rule | Value | + * |---|---| + * | Enum Values | invalid_key, invalid_key_format, failed_to_save, account_not_found, has_guardian, is_multisig | + */ + "key import error"?: + | "invalid_key" + | "invalid_key_format" + | "failed_to_save" + | "account_not_found" + | "has_guardian" + | "is_multisig" } export interface TransactionReviewedProperties { + /** + * Describes host part of an URL e.g. app.ekubo.org for + */ host?: string /** * References the "Key" e.g. **insufficient token received** @@ -719,10 +790,15 @@ export interface TransactionReviewedProperties { */ "simulation error message"?: string "simulation succeeded"?: boolean + /** + * Used for Staking related transaction type only. + * Staking providers are \[argent, nethermind, ...\] + */ + "staking provider"?: string /** * | Rule | Value | * |---|---| - * | Enum Values | inapp swap, inapp send, upgrade contract, enable smart account, disable smart account, keep smart account, deploy user account, deploy multisig account, add owner, remove owner, set confirmation, submit transaction intent, dapp, declare contract, deploy contract, replace owner, remove guardian, add guardian, reject onchain | + * | Enum Values | inapp swap, inapp send, upgrade contract, enable smart account, disable smart account, keep smart account, deploy user account, deploy multisig account, add owner, remove owner, set confirmation, submit transaction intent, dapp, declare contract, deploy contract, replace owner, remove guardian, add guardian, reject onchain, stake, claim staked rewards, initialise withdraw, finalise withdraw | */ "transaction type"?: | "inapp swap" @@ -744,6 +820,10 @@ export interface TransactionReviewedProperties { | "remove guardian" | "add guardian" | "reject onchain" + | "stake" + | "claim staked rewards" + | "initialise withdraw" + | "finalise withdraw" /** * This is a REQUIRED property, and it must be fired for ALL events on this project. * @@ -753,9 +833,9 @@ export interface TransactionReviewedProperties { * * | Rule | Value | * |---|---| - * | Enum Values | browser extension, mobile, web | + * | Enum Values | browser extension, mobile, web, telegram | */ - "wallet platform": "browser extension" | "mobile" | "web" + "wallet platform": "browser extension" | "mobile" | "web" | "telegram" } export interface TransactionSubmittedProperties { @@ -781,8 +861,16 @@ export interface TransactionSubmittedProperties { * | Enum Values | standard, smart, multisig | */ "account type": "standard" | "smart" | "multisig" + /** + * Describes host part of an URL e.g. app.ekubo.org for + */ host?: string "is deployment"?: boolean + /** + * Used for Staking related transaction type only. + * Staking providers are \[argent, nethermind, ...\] + */ + "staking provider"?: string /** * | Rule | Value | * |---|---| @@ -792,7 +880,7 @@ export interface TransactionSubmittedProperties { /** * | Rule | Value | * |---|---| - * | Enum Values | inapp swap, inapp send, upgrade contract, enable smart account, disable smart account, keep smart account, deploy user account, deploy multisig account, add owner, remove owner, set confirmation, submit transaction intent, dapp, declare contract, deploy contract, replace owner, remove guardian, add guardian, reject onchain | + * | Enum Values | inapp swap, inapp send, upgrade contract, enable smart account, disable smart account, keep smart account, deploy user account, deploy multisig account, add owner, remove owner, set confirmation, submit transaction intent, dapp, declare contract, deploy contract, replace owner, remove guardian, add guardian, reject onchain, stake, claim staked rewards, initialise withdraw, finalise withdraw | */ "transaction type"?: | "inapp swap" @@ -814,6 +902,10 @@ export interface TransactionSubmittedProperties { | "remove guardian" | "add guardian" | "reject onchain" + | "stake" + | "claim staked rewards" + | "initialise withdraw" + | "finalise withdraw" /** * This is a REQUIRED property, and it must be fired for ALL events on this project. * @@ -823,9 +915,9 @@ export interface TransactionSubmittedProperties { * * | Rule | Value | * |---|---| - * | Enum Values | browser extension, mobile, web | + * | Enum Values | browser extension, mobile, web, telegram | */ - "wallet platform": "browser extension" | "mobile" | "web" + "wallet platform": "browser extension" | "mobile" | "web" | "telegram" /** * | Rule | Value | * |---|---| @@ -844,9 +936,9 @@ export interface WalletLocalStorageClearedProperties { * * | Rule | Value | * |---|---| - * | Enum Values | browser extension, mobile, web | + * | Enum Values | browser extension, mobile, web, telegram | */ - "wallet platform": "browser extension" | "mobile" | "web" + "wallet platform": "browser extension" | "mobile" | "web" | "telegram" } export interface WalletRestoredProperties { @@ -859,9 +951,9 @@ export interface WalletRestoredProperties { * * | Rule | Value | * |---|---| - * | Enum Values | browser extension, mobile, web | + * | Enum Values | browser extension, mobile, web, telegram | */ - "wallet platform": "browser extension" | "mobile" | "web" + "wallet platform": "browser extension" | "mobile" | "web" | "telegram" } export class Identify implements BaseEvent { @@ -1064,6 +1156,10 @@ export class OnboardingVerificationCodeResent implements BaseEvent { } } +export class StakingEditButtonClicked implements BaseEvent { + event_type = "Staking Edit Button Clicked" +} + export class SwapQuoteFailed implements BaseEvent { event_type = "Swap Quote Failed" @@ -1080,6 +1176,24 @@ export class SwapTabClicked implements BaseEvent { } } +export class TestnetAccountImportCompleted implements BaseEvent { + event_type = "Testnet Account Import Completed" + + constructor( + public event_properties?: TestnetAccountImportCompletedProperties, + ) { + this.event_properties = event_properties + } +} + +export class TestnetAccountImportFailed implements BaseEvent { + event_type = "Testnet Account Import Failed" + + constructor(public event_properties?: TestnetAccountImportFailedProperties) { + this.event_properties = event_properties + } +} + export class TransactionReviewed implements BaseEvent { event_type = "Transaction Reviewed" @@ -1254,6 +1368,8 @@ export class Ampli { * Regarding properties: account index: 0 * * + * Owner: Ko Sakuma + * * @param properties The event's properties (e.g. account index) * @param options Amplitude event options. */ @@ -1275,7 +1391,9 @@ export class Ampli { * * * - * @param properties The event's properties (e.g. account chain) + * Owner: Ko Sakuma + * + * @param properties The event's properties (e.g. account index) * @param options Amplitude event options. */ accountDeployed( @@ -1300,6 +1418,8 @@ export class Ampli { * * * + * Owner: Ko Sakuma + * * @param properties The event's properties (e.g. wallet platform) * @param options Amplitude event options. */ @@ -1321,6 +1441,8 @@ export class Ampli { * * * + * Owner: Ko Sakuma + * * @param properties The event's properties (e.g. wallet platform) * @param options Amplitude event options. */ @@ -1340,10 +1462,12 @@ export class Ampli { * * Relevant properties: "provider" which defines the options of the explorer * - * Other comments: n/a + * Other comments: n/a It defaults to Voyager since 5.18 + * + * Screenshots: * - * Screenshots: * + * Owner: Ko Sakuma * * @param properties The event's properties (e.g. provider) * @param options Amplitude event options. @@ -1369,6 +1493,8 @@ export class Ampli { * * * + * Owner: Ko Sakuma + * * @param properties The event's properties (e.g. wallet platform) * @param options Amplitude event options. */ @@ -1403,6 +1529,8 @@ export class Ampli { * * * + * Owner: Ko Sakuma + * * @param properties The event's properties (e.g. host) * @param options Amplitude event options. */ @@ -1429,6 +1557,8 @@ export class Ampli { * * * + * Owner: Ko Sakuma + * * @param properties The event's properties (e.g. wallet platform) * @param options Amplitude event options. */ @@ -1454,6 +1584,8 @@ export class Ampli { * * * + * Owner: Ko Sakuma + * * @param properties The event's properties (e.g. account type) * @param options Amplitude event options. */ @@ -1481,6 +1613,8 @@ export class Ampli { * * * + * Owner: Ko Sakuma + * * @param properties The event's properties (e.g. wallet platform) * @param options Amplitude event options. */ @@ -1506,6 +1640,8 @@ export class Ampli { * * * + * Owner: Ko Sakuma + * * @param properties The event's properties (e.g. accounts added) * @param options Amplitude event options. */ @@ -1528,6 +1664,8 @@ export class Ampli { * Other comments: This does not include Session Key Singing. See Seesion Key Signed for such event. * * + * Owner: Ko Sakuma + * * @param properties The event's properties (e.g. from outside) * @param options Amplitude event options. */ @@ -1553,6 +1691,8 @@ export class Ampli { * * * + * Owner: Ko Sakuma + * * @param properties The event's properties (e.g. provider) * @param options Amplitude event options. */ @@ -1571,6 +1711,8 @@ export class Ampli { * * * + * Owner: Ko Sakuma + * * @param properties The event's properties (e.g. account type) * @param options Amplitude event options. */ @@ -1593,6 +1735,8 @@ export class Ampli { * * * + * Owner: Ko Sakuma + * * @param properties The event's properties (e.g. analytics activated) * @param options Amplitude event options. */ @@ -1616,6 +1760,8 @@ export class Ampli { * * * + * Owner: Ko Sakuma + * * @param properties The event's properties (e.g. account type) * @param options Amplitude event options. */ @@ -1641,6 +1787,8 @@ export class Ampli { * **Mobile:** * This is fired when: the user taps CTA, and before the terms get accepted. * + * Owner: Ko Sakuma + * * @param properties The event's properties (e.g. onboarding experiment) * @param options Amplitude event options. */ @@ -1665,6 +1813,8 @@ export class Ampli { * * * + * Owner: Ko Sakuma + * * @param properties The event's properties (e.g. onboarding experiment) * @param options Amplitude event options. */ @@ -1688,6 +1838,8 @@ export class Ampli { * Screenshots: * * + * Owner: Ko Sakuma + * * @param properties The event's properties (e.g. onboarding experiment) * @param options Amplitude event options. */ @@ -1714,6 +1866,8 @@ export class Ampli { * * ![](https://com-amplitude-orbit-media-prod-eu.s3.eu-central-1.amazonaws.com/100001676/dcd721f9-0ca3-4d06-a221-b37287bda59b.jpeg?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=ASIAS6JMKSBO6OET5WMY%2F20240726%2Feu-central-1%2Fs3%2Faws4_request&X-Amz-Date=20240726T093407Z&X-Amz-Expires=3600&X-Amz-Security-Token=IQoJb3JpZ2luX2VjEPf%2F%2F%2F%2F%2F%2F%2F%2F%2F%2FwEaDGV1LWNlbnRyYWwtMSJHMEUCIQDuGcmhQU%2BT4Lw1dcPqFIFszZ42kwer8BYxZtoCwVX0aQIgERIxJFRc0a1ssKc8mDs6NSdO4lnvxyTM9GfsfAnqs4gqzQUI0P%2F%2F%2F%2F%2F%2F%2F%2F%2F%2FARADGgwyMDI0OTMzMDA4MjkiDMPxxl%2FKeojrdBx2ACqhBY%2FTYFuu%2BmDylyOlOQZQkz2dR2Lgqa1QYrBrMylxwK3QWotiv2poLthwp2yVwS6dbImvvRTSwQQtkgYI%2FMkBTsiEn7rPeS%2BboUxXl2Ki2%2Fjtv3s0lqaHRKLUgbmG9sOAWtELS2KtJnNTJkbcoD1P0ZpCvKPjd9lRR2yrWMXRfiO6N0epTXL%2BBoDg3de%2FV77YQnLIA8ZQEHXchnq43IvZgO9pDrgH5AO%2Bd%2FdVDvyqLeCPyrO9VQWNGC1%2BAbZy3LcNxk7kA%2FN%2BoZP7SiGaTiCwKk4MOPhJ0Cz9B%2BM1hBYy%2FJ5HVeMW1S3W%2FcfxgQO6g%2FZ5UhveVWgW7h6S2xacfn3DBCuPtLzW9k9H5x65jbB9jtnYbZqrVJ4uutKH9G3MuJeiBfnsFr4L47PmVIxf2I9xKlx5Hak28%2BPv%2FYau2jmKhWCi4YUlmKD5AgZ2AWK%2B8MEee%2BeCr94DKd%2F2TT0cXzAdvSqMDBm9Oe%2FL8wHcG63eW49QfIgkPKbvIyGsjn%2FCzcQwzo4FluhGKr99RR%2BRYJNnDlQ7G3g4GmYOLlmmLztdfVkoKWjhT6piS9Yc00yB%2FDvHeWIypVyv%2Bu4IS6jwD53X8e32oOhnyj70fLowZOAX6t%2FfhSbJXnQKcS%2FZLVOomIHYqH%2Bmlz1wnF38Ty7kbUf4rkdPyorTH037kUneIrzMchipKDMbjNbxGxs1qIdGFOmZ%2BvkeZCesfmyROcoUfcTh106s7kTZHrb3tHefRaDzyrMse%2BK6Bn73GlM7V%2FJqCiQCyZ%2BXmi3oDIvlSp3MfqANlS%2BD5eJdML1yM3w7%2Bjo1hmFTOFMyOYas2pDfVSqdqa6mJhqP2Ue4mFjDEvIsIeR%2FoXyDScZiCPA3fXK5Y3tM4lWqCbOK2CEYDJADs0ctdPPKd68w%2FoWNtQY6sQFJbj8OHAmoD%2F8ISKkA8XMRm9oYpQh4ZDNbVFRxUZ18mfp5d2tDKjU79Tnd35xhrrolylB2eUiStM6ouQYCRSHxCa7u0IPYLarkYzaYVUtM7qET70z%2F4qq1y95%2BTcFTRCIlzNvsF3qSaGsNfVM%2FmnWMg4DFFNWS9TDfBk%2FiFIbLguP1%2Bpfx7KGKstgaMbNgSc22Qame3eATULR3UW4w6t0ufS8bEi9GskIxIZheKdxmAME%3D&X-Amz-Signature=675fcc9125dc72fca0687b83b950bb139fb664fe9a1e7e2a2ba2f8b0f71a3986&X-Amz-SignedHeaders=host&x-id=GetObject) * + * Owner: Ko Sakuma + * * @param properties The event's properties (e.g. onboarding experiment) * @param options Amplitude event options. */ @@ -1729,7 +1883,7 @@ export class Ampli { * * [View in Tracking Plan](https://data.eu.amplitude.com/argent/Argent%20(dev)/events/main/latest/Onboarding%20Verification%20Code%20Accepted) * - * Event has no description in tracking plan. + * Owner: Ko Sakuma * * @param properties The event's properties (e.g. onboarding experiment) * @param options Amplitude event options. @@ -1746,7 +1900,7 @@ export class Ampli { * * [View in Tracking Plan](https://data.eu.amplitude.com/argent/Argent%20(dev)/events/main/latest/Onboarding%20Verification%20Code%20Rejected) * - * Event has no description in tracking plan. + * Owner: Ko Sakuma * * @param properties The event's properties (e.g. onboarding experiment) * @param options Amplitude event options. @@ -1763,7 +1917,7 @@ export class Ampli { * * [View in Tracking Plan](https://data.eu.amplitude.com/argent/Argent%20(dev)/events/main/latest/Onboarding%20Verification%20Code%20Resent) * - * Event has no description in tracking plan. + * Owner: Ko Sakuma * * @param properties The event's properties (e.g. onboarding experiment) * @param options Amplitude event options. @@ -1775,6 +1929,25 @@ export class Ampli { return this.track(new OnboardingVerificationCodeResent(properties), options); } + /** + * Staking Edit Button Clicked + * + * [View in Tracking Plan](https://data.eu.amplitude.com/argent/Argent%20(dev)/events/main/latest/Staking%20Edit%20Button%20Clicked) + * + * AX: This event is fired when: the user clicks the Edit button on the Stake STRK screen + * Other notes: This is meant to track how much users are interested in exploring "other options" + * + * + * + * + * @param options Amplitude event options. + */ + stakingEditButtonClicked( + options?: EventOptions, + ) { + return this.track(new StakingEditButtonClicked(), options); + } + /** * Swap Quote Failed * @@ -1789,6 +1962,8 @@ export class Ampli { * Screenshots: * (@dev, pls can you include an example of swap quote failure) * + * Owner: Ko Sakuma + * * @param properties The event's properties (e.g. error type) * @param options Amplitude event options. */ @@ -1815,6 +1990,8 @@ export class Ampli { * * * + * Owner: Ko Sakuma + * * @param properties The event's properties (e.g. wallet platform) * @param options Amplitude event options. */ @@ -1825,6 +2002,40 @@ export class Ampli { return this.track(new SwapTabClicked(properties), options); } + /** + * Testnet Account Import Completed + * + * [View in Tracking Plan](https://data.eu.amplitude.com/argent/Argent%20(dev)/events/main/latest/Testnet%20Account%20Import%20Completed) + * + * This event is fired when the user tries to import an account on Sepolia, and succeeded + * + * @param properties The event's properties (e.g. imported account class hash) + * @param options Amplitude event options. + */ + testnetAccountImportCompleted( + properties?: TestnetAccountImportCompletedProperties, + options?: EventOptions, + ) { + return this.track(new TestnetAccountImportCompleted(properties), options); + } + + /** + * Testnet Account Import Failed + * + * [View in Tracking Plan](https://data.eu.amplitude.com/argent/Argent%20(dev)/events/main/latest/Testnet%20Account%20Import%20Failed) + * + * This event is fired when the user tries to import an account on Sepolia, and failed + * + * @param properties The event's properties (e.g. imported account class hash) + * @param options Amplitude event options. + */ + testnetAccountImportFailed( + properties?: TestnetAccountImportFailedProperties, + options?: EventOptions, + ) { + return this.track(new TestnetAccountImportFailed(properties), options); + } + /** * Transaction Reviewed * @@ -1840,6 +2051,8 @@ export class Ampli { * * * + * Owner: Ko Sakuma + * * @param properties The event's properties (e.g. host) * @param options Amplitude event options. */ @@ -1864,6 +2077,8 @@ export class Ampli { * * * + * Owner: Ko Sakuma + * * @param properties The event's properties (e.g. account index) * @param options Amplitude event options. */ @@ -1886,6 +2101,8 @@ export class Ampli { * * * + * Owner: Ko Sakuma + * * @param properties The event's properties (e.g. wallet platform) * @param options Amplitude event options. */ @@ -1913,6 +2130,8 @@ export class Ampli { * This event is fired when the user succesufully enters his password for the first time in a new browser (and therefore managed to restore the wallet) * * + * Owner: Ko Sakuma + * * @param properties The event's properties (e.g. wallet platform) * @param options Amplitude event options. */ diff --git a/packages/extension/src/assets/barlow/LICENSE b/packages/extension/src/assets/barlow/LICENSE new file mode 100644 index 000000000..11f252809 --- /dev/null +++ b/packages/extension/src/assets/barlow/LICENSE @@ -0,0 +1,93 @@ +Copyright 2017 The Barlow Project Authors (https://github.com/jpt/barlow) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/packages/extension/src/assets/barlow/barlow-latin-300-normal.woff b/packages/extension/src/assets/barlow/barlow-latin-300-normal.woff new file mode 100644 index 0000000000000000000000000000000000000000..36737125571c74dadd06746034dd5029169eb137 GIT binary patch literal 18420 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 literal 0 HcmV?d00001 diff --git a/packages/extension/src/assets/barlow/barlow-latin-400-normal.woff b/packages/extension/src/assets/barlow/barlow-latin-400-normal.woff new file mode 100644 index 0000000000000000000000000000000000000000..f5a20a66103ecf43e85b199e00510e24443ff5cd GIT binary patch literal 18464 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 literal 0 HcmV?d00001 diff --git a/packages/extension/src/assets/barlow/barlow-latin-500-normal.woff2 b/packages/extension/src/assets/barlow/barlow-latin-500-normal.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..bd02c3d467de3f048d2f95f8f9a163624310465c GIT binary patch literal 20960 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 Tu)qC@^=ALX-E{KC761SM5QcHM literal 0 HcmV?d00001 diff --git a/packages/extension/src/assets/barlow/barlow-latin-600-normal.woff b/packages/extension/src/assets/barlow/barlow-latin-600-normal.woff new file mode 100644 index 0000000000000000000000000000000000000000..849ac89262bffa801b80c529268f30c5a9edbf16 GIT binary patch literal 18748 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 literal 0 HcmV?d00001 diff --git a/packages/extension/src/assets/barlow/barlow-latin-700-normal.woff b/packages/extension/src/assets/barlow/barlow-latin-700-normal.woff new file mode 100644 index 0000000000000000000000000000000000000000..9fe6949295006bf3f9e40a791ac605a5d0eb2e5c GIT binary patch literal 18644 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 gm9Ht-2kqat5VQ9hz=bbGKoXf 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 o?EYpP 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 P00000NkvXXu0mjfC^psM literal 0 HcmV?d00001 diff --git a/packages/extension/src/background/accountDeploy.ts b/packages/extension/src/background/accountDeploy.ts index 9d2447d7c..8ecf743de 100644 --- a/packages/extension/src/background/accountDeploy.ts +++ b/packages/extension/src/background/accountDeploy.ts @@ -1,4 +1,5 @@ -import { BlockNumber, CallData, num } from "starknet" +import type { BlockNumber, num } from "starknet" +import { CallData } from "starknet" import type { BaseWalletAccount, WalletAccount } from "../shared/wallet.model" import type { Wallet } from "./wallet" import type { IBackgroundActionService } from "./services/action/IBackgroundActionService" @@ -16,7 +17,7 @@ export const addDeployAccountAction = async ({ actionService, wallet, }: IDeployAccount) => { - const walletAccount = await wallet.getAccount(account) + const walletAccount = await wallet.getArgentAccount(account.id) if (!walletAccount) { throw new Error("Account not found") } @@ -44,7 +45,7 @@ export const addDeployAccountAction = async ({ { title: "Activate account", shortTitle: "Account activation", - icon: "DeployIcon", + icon: "RocketSecondaryIcon", }, ) } diff --git a/packages/extension/src/background/accountDeployAction.ts b/packages/extension/src/background/accountDeployAction.ts index c041a0900..6fb1cdfe5 100644 --- a/packages/extension/src/background/accountDeployAction.ts +++ b/packages/extension/src/background/accountDeployAction.ts @@ -1,10 +1,16 @@ -import { getTxVersionFromFeeToken } from "@argent/x-shared" -import { ExtensionActionItemOfType } from "../shared/actionQueue/types" +import { + estimatedFeeToMaxResourceBounds, + getTxVersionFromFeeToken, +} from "@argent/x-shared" +import type { ExtensionActionItemOfType } from "../shared/actionQueue/types" import { addTransaction } from "../shared/transactions/store" import { checkTransactionHash } from "../shared/transactions/utils" -import { Wallet } from "./wallet" +import type { Wallet } from "./wallet" import { sanitizeAccountType } from "../shared/utils/sanitizeAccountType" -import { DeployActionExtra } from "../shared/actionQueue/schema" +import type { DeployActionExtra } from "../shared/actionQueue/schema" +import { getEstimatedFees } from "../shared/transactionSimulation/fees/estimatedFeesRepository" +import { TransactionType } from "starknet" +import { TransactionError } from "../shared/errors/transaction" export const accountDeployAction = async ( action: ExtensionActionItemOfType<"DEPLOY_ACCOUNT">, @@ -21,7 +27,7 @@ export const accountDeployAction = async ( } const { feeTokenAddress } = extra - const selectedAccount = await wallet.getAccount(baseAccount) + const selectedAccount = await wallet.getArgentAccount(baseAccount.id) const accountNeedsDeploy = selectedAccount?.needsDeploy @@ -29,11 +35,29 @@ export const accountDeployAction = async ( throw Error("Account already deployed") } + const accountDeployPayload = + await wallet.getAccountDeploymentPayload(selectedAccount) + + const preComputedFees = await getEstimatedFees({ + type: TransactionType.DEPLOY_ACCOUNT, + payload: accountDeployPayload, + }) + + if (!preComputedFees) { + throw new TransactionError({ code: "NO_PRE_COMPUTED_FEES" }) + } + const version = getTxVersionFromFeeToken(feeTokenAddress) - const { account, txHash } = await wallet.deployAccount(selectedAccount, { + const deployDetails = { version, - }) + ...estimatedFeeToMaxResourceBounds(preComputedFees.transactions), + } + + const { account, txHash } = await wallet.deployAccount( + selectedAccount, + deployDetails, + ) if (!checkTransactionHash(txHash)) { throw Error( diff --git a/packages/extension/src/background/accountMessaging.ts b/packages/extension/src/background/accountMessaging.ts index 6258824df..1f5463cb0 100644 --- a/packages/extension/src/background/accountMessaging.ts +++ b/packages/extension/src/background/accountMessaging.ts @@ -1,5 +1,6 @@ -import { AccountMessage } from "../shared/messages/AccountMessage" -import { HandleMessage, UnhandledMessage } from "./background" +import type { AccountMessage } from "../shared/messages/AccountMessage" +import type { HandleMessage } from "./background" +import { UnhandledMessage } from "./background" export const handleAccountMessage: HandleMessage = async ({ msg, diff --git a/packages/extension/src/background/accountUpgrade.ts b/packages/extension/src/background/accountUpgrade.ts index fac42cf66..4b29c3bd0 100644 --- a/packages/extension/src/background/accountUpgrade.ts +++ b/packages/extension/src/background/accountUpgrade.ts @@ -1,9 +1,12 @@ import { CallData } from "starknet" import { networkService } from "../shared/network/service" -import { ArgentAccountType, BaseWalletAccount } from "../shared/wallet.model" -import { IBackgroundActionService } from "./services/action/IBackgroundActionService" -import { Wallet } from "./wallet" +import type { + ArgentAccountType, + BaseWalletAccount, +} from "../shared/wallet.model" +import type { IBackgroundActionService } from "./services/action/IBackgroundActionService" +import type { Wallet } from "./wallet" import { AccountError } from "../shared/errors/account" import { addressSchema, isAccountV5 } from "@argent/x-shared" import { sanitizeAccountType } from "../shared/utils/sanitizeAccountType" @@ -21,11 +24,11 @@ export const upgradeAccount = async ({ actionService, targetImplementationType, }: IUpgradeAccount) => { - const fullAccount = await wallet.getAccount(account) + const fullAccount = await wallet.getAccount(account.id) if (!fullAccount) { throw new AccountError({ code: "NOT_FOUND" }) } - const starknetAccount = await wallet.getStarknetAccount(account) + const starknetAccount = await wallet.getStarknetAccount(account.id) const accountType = targetImplementationType ?? fullAccount.type @@ -81,7 +84,7 @@ export const upgradeAccount = async ({ }, { title: "Upgrade account", - icon: "UpgradeIcon", + icon: "UpgradeSecondaryIcon", }, ) } diff --git a/packages/extension/src/background/actionHandlers.ts b/packages/extension/src/background/actionHandlers.ts index 9a7ce2596..f521264bf 100644 --- a/packages/extension/src/background/actionHandlers.ts +++ b/packages/extension/src/background/actionHandlers.ts @@ -1,28 +1,27 @@ import { TXV3_ACCOUNT_CLASS_HASH } from "@argent/x-shared" import { stark } from "starknet" import { accountService } from "../shared/account/service" -import { ExtensionActionItem } from "../shared/actionQueue/types" +import type { ExtensionActionItem } from "../shared/actionQueue/types" import { ampli } from "../shared/analytics" -import { MessageType } from "../shared/messages" +import type { MessageType } from "../shared/messages" import { multisigArraySignatureSchema } from "../shared/multisig/multisig.model" import { networkSchema } from "../shared/network" import { networkService } from "../shared/network/service" import { preAuthorizationService } from "../shared/preAuthorization" import { assertNever } from "../shared/utils/assertNever" -import { encodeChainId } from "../shared/utils/encodeChainId" import { isEqualWalletAddress } from "../shared/wallet.service" import { accountDeployAction } from "./accountDeployAction" import { addMultisigDeployAction } from "./multisig/multisigDeployAction" -import { - TransactionAction, - executeTransactionAction, -} from "./transactions/transactionExecution" +import type { TransactionAction } from "./transactions/transactionExecution" +import { executeTransactionAction } from "./transactions/transactionExecution" import { udcDeclareContract, udcDeployContract } from "./udcAction" -import { Wallet } from "./wallet" +import type { Wallet } from "./wallet" import { respondToHost } from "./respond" import { backgroundUIService } from "./services/ui" import { isNetworkOnlyPlaceholderAccount } from "../shared/wallet.model" -import { ActionItemExtra } from "../shared/actionQueue/schema" +import type { ActionItemExtra } from "../shared/actionQueue/schema" +import { validateSignatureChainId } from "../shared/utils/validateSignatureChainId" +import { addTransactionHash } from "../shared/transactions/transactionHashes/transactionHashesRepository" const handleTransactionAction = async ({ action, @@ -151,32 +150,31 @@ export const handleActionApproval = async ( } } - // let's compare encoded formats of both chainIds - const encodedDomainChainId = encodeChainId(typedData.domain.chainId) - const encodedSelectedChainId = encodeChainId( - selectedAccount.network.chainId, + const validateSignatureChainIdResult = validateSignatureChainId( + selectedAccount, + typedData, ) - // typedData.domain.chainId is optional, so we need to check if it exists - if ( - encodedDomainChainId && - encodedSelectedChainId !== encodedDomainChainId - ) { + + if (!validateSignatureChainIdResult.success) { return { type: "SIGNATURE_FAILURE", data: { - error: `Cannot sign the message from a different chainId. Expected ${encodedSelectedChainId}, got ${encodedDomainChainId}`, + error: validateSignatureChainIdResult.error, actionHash, }, } } try { + const messageHash = await starknetAccount.hashMessage(typedData) + await addTransactionHash(actionHash, messageHash) const signature = await starknetAccount.signMessage(typedData) let formattedSignature if (selectedAccount.type === "multisig") { - const multisigAccount = - await wallet.getMultisigAccount(selectedAccount) + const multisigAccount = await wallet.getMultisigAccount( + selectedAccount.id, + ) // Should be [requestId, signer, r, s] const parsedSignature = @@ -239,7 +237,7 @@ export const handleActionApproval = async ( type: "APPROVE_REQUEST_ADD_CUSTOM_NETWORK", data: { actionHash }, } - } catch (error) { + } catch { return { type: "REJECT_REQUEST_ADD_CUSTOM_NETWORK", data: { actionHash }, @@ -274,9 +272,10 @@ export const handleActionApproval = async ( isEqualWalletAddress(account, currentlySelectedAccount), ) - const selectedAccount = await wallet.selectAccount( - existingAccountOnNetwork ?? accountsOnNetwork[0] ?? newAccount, - ) + const account = + existingAccountOnNetwork ?? accountsOnNetwork[0] ?? newAccount + + const selectedAccount = await wallet.selectAccount(account.id) if (isNetworkOnlyPlaceholderAccount(selectedAccount)) { throw Error(`No accounts found on network with chainId ${chainId}`) @@ -285,7 +284,7 @@ export const handleActionApproval = async ( type: "APPROVE_REQUEST_SWITCH_CUSTOM_NETWORK", data: { actionHash, selectedAccount }, } - } catch (error) { + } catch { return { type: "REJECT_REQUEST_SWITCH_CUSTOM_NETWORK", data: { actionHash }, diff --git a/packages/extension/src/background/actionMessaging.ts b/packages/extension/src/background/actionMessaging.ts index c1bcc8a2f..fcd9de83a 100644 --- a/packages/extension/src/background/actionMessaging.ts +++ b/packages/extension/src/background/actionMessaging.ts @@ -1,6 +1,6 @@ -import { ActionMessage } from "../shared/messages/ActionMessage" +import type { ActionMessage } from "../shared/messages/ActionMessage" import { UnhandledMessage } from "./background" -import { HandleMessage } from "./background" +import type { HandleMessage } from "./background" export const handleActionMessage: HandleMessage = async ({ msg, diff --git a/packages/extension/src/background/activeTabs.ts b/packages/extension/src/background/activeTabs.ts index 3db2636d8..8bf0d865e 100644 --- a/packages/extension/src/background/activeTabs.ts +++ b/packages/extension/src/background/activeTabs.ts @@ -1,6 +1,7 @@ import browser from "webextension-polyfill" -import { MessageType, sendMessage } from "../shared/messages" +import type { MessageType } from "../shared/messages" +import { sendMessage } from "../shared/messages" import { UniqueSet } from "./utils/uniqueSet" interface Tab { diff --git a/packages/extension/src/background/background.ts b/packages/extension/src/background/background.ts index d0c46ac59..d0dd8830e 100644 --- a/packages/extension/src/background/background.ts +++ b/packages/extension/src/background/background.ts @@ -1,11 +1,11 @@ -import browser from "webextension-polyfill" +import type browser from "webextension-polyfill" -import { IBackgroundActionService } from "./services/action/IBackgroundActionService" +import type { IBackgroundActionService } from "./services/action/IBackgroundActionService" import type { MessagingKeys } from "./keys/messagingKeys" import type { Respond } from "./respond" -import { Wallet } from "./wallet" -import { TransactionTrackerWorker } from "./services/transactionTracker/worker/TransactionTrackerWorker" -import { IFeeTokenService } from "../shared/feeToken/service/IFeeTokenService" +import type { Wallet } from "./wallet" +import type { TransactionTrackerWorker } from "./services/transactionTracker/worker/TransactionTrackerWorker" +import type { IFeeTokenService } from "../shared/feeToken/service/IFeeTokenService" export interface BackgroundService { wallet: Wallet diff --git a/packages/extension/src/background/crypto.ts b/packages/extension/src/background/crypto.ts index 3e1922637..98877b6d0 100644 --- a/packages/extension/src/background/crypto.ts +++ b/packages/extension/src/background/crypto.ts @@ -1,4 +1,5 @@ -import { EncryptJWT, KeyLike, compactDecrypt, importJWK } from "jose" +import type { KeyLike } from "jose" +import { EncryptJWT, compactDecrypt, importJWK } from "jose" import { bytesToUft8 } from "../shared/utils/encode" diff --git a/packages/extension/src/background/devnet/declareAccounts.ts b/packages/extension/src/background/devnet/declareAccounts.ts index bd6554f7d..46598a7e0 100644 --- a/packages/extension/src/background/devnet/declareAccounts.ts +++ b/packages/extension/src/background/devnet/declareAccounts.ts @@ -1,10 +1,11 @@ -import { memoize } from "lodash-es" -import { Account, AccountInterface, CairoAssembly, hash } from "starknet" +import memoize from "memoizee" +import type { AccountInterface, CairoAssembly } from "starknet" +import { Account, hash } from "starknet" import urlJoin from "url-join" -import { Network, getProvider } from "../../shared/network" -import { LoadContracts } from "../wallet/loadContracts" -import { cairoAssemblySchema } from "@argent/x-shared" +import type { Network } from "../../shared/network" +import { getProvider } from "../../shared/network" +import type { LoadContracts } from "../wallet/loadContracts" interface PreDeployedAccount { address: string @@ -33,7 +34,7 @@ export const getPreDeployedAccount = async ( provider, preDeployedAccount.address, preDeployedAccount.private_key, - "0", // Devnet is currently supporting only cairo 0 + "1", ) } catch (e) { console.warn(`Failed to get pre-deployed account: ${e}`) @@ -77,7 +78,10 @@ export const declareContracts = memoize( return accountClassHash ?? computedAccountClassHash }, - (network) => `${network.rpcUrl}`, + { + promise: true, + normalizer: ([network]) => `${network.rpcUrl}`, + }, ) export const checkIfClassIsDeclared = async ( @@ -89,7 +93,7 @@ export const checkIfClassIsDeclared = async ( console.log("Contract already declared", classHash) return Boolean(contract) - } catch (error) { + } catch { console.warn("Contract not declared", classHash) return false } diff --git a/packages/extension/src/background/index.ts b/packages/extension/src/background/index.ts index 2679a3e39..fdc48de47 100644 --- a/packages/extension/src/background/index.ts +++ b/packages/extension/src/background/index.ts @@ -22,6 +22,7 @@ try { try { // catch any errors from init.ts + // eslint-disable-next-line @typescript-eslint/no-require-imports require("./init") } catch (error) { console.error("Fatal exception in background/init.ts", error) diff --git a/packages/extension/src/background/init.ts b/packages/extension/src/background/init.ts index 92cad5dc6..d9cc773b8 100644 --- a/packages/extension/src/background/init.ts +++ b/packages/extension/src/background/init.ts @@ -32,5 +32,6 @@ initWorkers() // hot reload in development if (IS_DEV) { + // eslint-disable-next-line @typescript-eslint/no-require-imports require("./dev/hotReload") } diff --git a/packages/extension/src/background/keys/messagingKeys.ts b/packages/extension/src/background/keys/messagingKeys.ts index b089eab1d..c38247ca6 100644 --- a/packages/extension/src/background/keys/messagingKeys.ts +++ b/packages/extension/src/background/keys/messagingKeys.ts @@ -1,4 +1,5 @@ -import { JWK, KeyLike, exportJWK, generateKeyPair, importJWK } from "jose" +import type { JWK, KeyLike } from "jose" +import { exportJWK, generateKeyPair, importJWK } from "jose" import browser from "webextension-polyfill" export interface MessagingKeys { @@ -29,7 +30,7 @@ export async function getMessagingKeys(): Promise { const privateKeyJwk = { alg: "ECDH-ES", ...exportedPrivateKey } const publicKeyJwk = { alg: "ECDH-ES", ...exportedPublicKey } - browser.storage.local.set({ + void browser.storage.local.set({ PRIVATE_KEY: JSON.stringify(privateKeyJwk), PUBLIC_KEY: JSON.stringify(publicKeyJwk), }) diff --git a/packages/extension/src/background/messageHandling/addMessageListeners.ts b/packages/extension/src/background/messageHandling/addMessageListeners.ts index 8ca7dc075..2c4baf6af 100644 --- a/packages/extension/src/background/messageHandling/addMessageListeners.ts +++ b/packages/extension/src/background/messageHandling/addMessageListeners.ts @@ -1,13 +1,15 @@ import browser from "webextension-polyfill" -import { StarknetMethodArgumentsSchemas } from "starknetkit/window" -import { MessageType } from "../../shared/messages" +import { StarknetMethodArgumentsSchemas } from "@argent/x-window" +import type { MessageType } from "../../shared/messages" import { handleMessage } from "./handle" import { isSessionKeyTypedData, sessionKeyMessageSchema, } from "../../shared/sessionKeys/schema" -import { isSessionKeysWhitelistedDomain } from "../../shared/sessionKeys/whitelist" import { getOriginFromSender } from "../../shared/messages/getOriginFromSender" +import { knownDappsService } from "../../shared/knownDapps/index" +import { isEmpty } from "lodash-es" +import { isLocalhost } from "../../shared/messages/isLocalhost" export const addMessageListeners = () => { const initialUrls = new Map() // tabId -> url @@ -44,13 +46,20 @@ export const addMessageListeners = () => { ]) /** if it is potentially session key then further validate the message payload and domain */ if (isSessionKeyTypedData(typedData)) { - if ( - !isSessionKeysWhitelistedDomain(getOriginFromSender(sender)) - ) { - throw new Error( - `The origin is not whitelisted for session keys`, - ) + const host = getOriginFromSender(sender) + + // Always allow localhost session key signing + if (!isLocalhost(host)) { + const knownDappData = + await knownDappsService.getDappByHost(host) + + if (isEmpty(knownDappData?.sessionConfig)) { + throw new Error( + `The origin is not whitelisted for session keys`, + ) + } } + await sessionKeyMessageSchema.parseAsync(typedData.message) } return handleMessage( @@ -69,24 +78,25 @@ export const addMessageListeners = () => { }) }) - // Store the initial URL of the tab, to detect redirects - const onBeforeNavigateListener = (details: any) => { - // We are saving the URL only if the navigation is happening in the top-level frame of a tab. There can only be navigation within a nested frame inside the webpage, such as an iframe - if (details.frameType === "outermost_frame") { - initialUrls.set(details.tabId, details.url) + if (browser.webNavigation?.onBeforeNavigate) { + // Store the initial URL of the tab, to detect redirects + const onBeforeNavigateListener = (details: any) => { + // We are saving the URL only if the navigation is happening in the top-level frame of a tab. There can only be navigation within a nested frame inside the webpage, such as an iframe + if (details.frameType === "outermost_frame") { + initialUrls.set(details.tabId, details.url) + } + } + if ( + !browser.webNavigation.onBeforeNavigate.hasListener( + onBeforeNavigateListener, + ) + ) { + browser.webNavigation.onBeforeNavigate.addListener( + onBeforeNavigateListener, + { + url: [{ urlMatches: ".*" }], + }, + ) } - } - - if ( - !browser.webNavigation.onBeforeNavigate.hasListener( - onBeforeNavigateListener, - ) - ) { - browser.webNavigation.onBeforeNavigate.addListener( - onBeforeNavigateListener, - { - url: [{ urlMatches: ".*" }], - }, - ) } } diff --git a/packages/extension/src/background/messageHandling/handle.ts b/packages/extension/src/background/messageHandling/handle.ts index 5af3b0c7f..8d00bc63d 100644 --- a/packages/extension/src/background/messageHandling/handle.ts +++ b/packages/extension/src/background/messageHandling/handle.ts @@ -1,15 +1,12 @@ -import { MessageType } from "../../shared/messages" +import type { MessageType } from "../../shared/messages" import { preAuthorizationService } from "../../shared/preAuthorization" import { migrateWallet } from "../migrations/wallet/storeMigration" import { backgroundActionService } from "../services/action" import { handleAccountMessage } from "../accountMessaging" import { handleActionMessage } from "../actionMessaging" import { addTab, hasTab, sendMessageToActiveTabs } from "../activeTabs" -import { - BackgroundService, - HandleMessage, - UnhandledMessage, -} from "../background" +import type { BackgroundService, HandleMessage } from "../background" +import { UnhandledMessage } from "../background" import { getMessagingKeys } from "../keys/messagingKeys" import { handleMiscellaneousMessage } from "../miscellaneousMessaging" import { handleNetworkMessage } from "../networkMessaging" @@ -22,7 +19,7 @@ import { handleTransactionMessage } from "../transactions/transactionMessaging" import { handleUdcMessaging } from "../udcMessaging" import { walletSingleton } from "../walletSingleton" import { safeMessages, safeIfPreauthorizedMessages } from "./messages" -import browser from "webextension-polyfill" +import type browser from "webextension-polyfill" import { feeTokenService } from "../../shared/feeToken/service" import { z } from "zod" import { getOriginFromSender } from "../../shared/messages/getOriginFromSender" diff --git a/packages/extension/src/background/messageHandling/messages.ts b/packages/extension/src/background/messageHandling/messages.ts index 58d670e0e..f5126fc2d 100644 --- a/packages/extension/src/background/messageHandling/messages.ts +++ b/packages/extension/src/background/messageHandling/messages.ts @@ -1,4 +1,4 @@ -import { MessageType } from "../../shared/messages" +import type { MessageType } from "../../shared/messages" export const safeMessages: MessageType["type"][] = [ "IS_PREAUTHORIZED", diff --git a/packages/extension/src/background/migrations/index.ts b/packages/extension/src/background/migrations/index.ts index ddce1b337..cb7287ab0 100644 --- a/packages/extension/src/background/migrations/index.ts +++ b/packages/extension/src/background/migrations/index.ts @@ -1,16 +1,16 @@ import browser from "webextension-polyfill" -import { restoreDefaultNetworks } from "./network/restoreDefaultNetworksMigration" import { KeyValueStorage } from "../../shared/storage" -import { runRemoveTestnet2Accounts, runV581Migration } from "./wallet" -import { runV59TokenMigration } from "./token/v5.9" -import { runDefaultTokenMigration } from "./token/v5.10" -import { runPreAuthorizationMigrationOld } from "./preAuthorizations/old" import { runLatestBalanceChangingActivityMigration } from "./activity/latestBalanceChangingActivityMigration" +import { restoreDefaultNetworks } from "./network/restoreDefaultNetworksMigration" +import { runPreAuthorizationMigrationOld } from "./preAuthorizations/old" +import { runDefaultTokenMigration } from "./token/v5.10" +import { runV59TokenMigration } from "./token/v5.9" import { migrateTxStatus, needsTxStatusMigration, } from "./transactions/migrateStatus" +import { runRemoveTestnet2Accounts, runV581Migration } from "./wallet" enum WalletMigrations { v581 = "wallet:v581", @@ -108,13 +108,11 @@ async function runUpdateMigrations() { } } -export const installMigrationListener = browser.runtime.onInstalled.addListener( - async (details) => { - if (details.reason === chrome.runtime.OnInstalledReason.INSTALL) { - await runInstallMigrations() - } - if (details.reason === chrome.runtime.OnInstalledReason.UPDATE) { - await runUpdateMigrations() - } - }, -) +browser.runtime.onInstalled.addListener((details) => { + if (details.reason === chrome.runtime.OnInstalledReason.INSTALL) { + void runInstallMigrations() + } + if (details.reason === chrome.runtime.OnInstalledReason.UPDATE) { + void runUpdateMigrations() + } +}) diff --git a/packages/extension/src/background/migrations/preAuthorizations/old.ts b/packages/extension/src/background/migrations/preAuthorizations/old.ts index 2cf1611ae..4b1aed268 100644 --- a/packages/extension/src/background/migrations/preAuthorizations/old.ts +++ b/packages/extension/src/background/migrations/preAuthorizations/old.ts @@ -25,7 +25,7 @@ export async function runPreAuthorizationMigrationOld() { const accountHostCombinations: PreAuthorization[] = old.flatMap((h) => allAccounts.map((a) => ({ - account: pick(a, "address", "networkId"), + account: pick(a, "id", "address", "networkId"), host: h, })), ) diff --git a/packages/extension/src/background/migrations/token/v5.9.ts b/packages/extension/src/background/migrations/token/v5.9.ts index 2c5caf415..2bb9eca4d 100644 --- a/packages/extension/src/background/migrations/token/v5.9.ts +++ b/packages/extension/src/background/migrations/token/v5.9.ts @@ -1,6 +1,6 @@ import { isEmpty } from "lodash-es" import { tokenService } from "../../../shared/token/__new/service" -import { Token } from "../../../shared/token/__new/types/token.model" +import type { Token } from "../../../shared/token/__new/types/token.model" import { equalToken, parsedDefaultTokens, diff --git a/packages/extension/src/background/migrations/wallet/testnet2Accounts.ts b/packages/extension/src/background/migrations/wallet/testnet2Accounts.ts index 9fa73d607..769b48422 100644 --- a/packages/extension/src/background/migrations/wallet/testnet2Accounts.ts +++ b/packages/extension/src/background/migrations/wallet/testnet2Accounts.ts @@ -33,6 +33,7 @@ export async function migrateTestnet2Accounts( await store.set({ selected: firstValidAccount ? { + id: firstValidAccount.id, address: firstValidAccount.address, networkId: firstValidAccount.networkId, } diff --git a/packages/extension/src/background/migrations/wallet/v5.8.1.test.ts b/packages/extension/src/background/migrations/wallet/v5.8.1.test.ts index dd4a6d8e3..9de3f2a90 100644 --- a/packages/extension/src/background/migrations/wallet/v5.8.1.test.ts +++ b/packages/extension/src/background/migrations/wallet/v5.8.1.test.ts @@ -1,10 +1,8 @@ import { describe, expect, it } from "vitest" import { determineMigrationNeededV581 } from "./v5.8.1" -import { - cryptoStarknetServiceMock, - getWalletStoreMock, -} from "../../wallet/test.utils" +import { cryptoStarknetServiceMock } from "../../wallet/test.utils" import { SignerType } from "../../../shared/wallet.model" +import { getWalletStoreMock } from "../../../shared/test.utils" describe("v5.8.1", () => { it("should detect falsey accounts for migration", async () => { diff --git a/packages/extension/src/background/migrations/wallet/v5.8.1.ts b/packages/extension/src/background/migrations/wallet/v5.8.1.ts index 7dd9e7ff9..ac00c9d13 100644 --- a/packages/extension/src/background/migrations/wallet/v5.8.1.ts +++ b/packages/extension/src/background/migrations/wallet/v5.8.1.ts @@ -1,18 +1,20 @@ import { getAccountContractAddress, isEqualAddress } from "@argent/x-shared" import { STANDARD_CAIRO_0_ACCOUNT_CLASS_HASH } from "../../../shared/network/constants" -import { +import type { IObjectStore, IRepository, } from "../../../shared/storage/__new/interface" -import { +import type { BaseWalletAccount, + WalletAccount, +} from "../../../shared/wallet.model" +import { defaultNetworkOnlyPlaceholderAccount, isNetworkOnlyPlaceholderAccount, - WalletAccount, } from "../../../shared/wallet.model" import { accountsEqual } from "../../../shared/utils/accountsEqual" -import { WalletCryptoStarknetService } from "../../wallet/crypto/WalletCryptoStarknetService" -import { WalletStorageProps } from "../../../shared/wallet/walletStore" +import type { WalletCryptoStarknetService } from "../../wallet/crypto/WalletCryptoStarknetService" +import type { WalletStorageProps } from "../../../shared/wallet/walletStore" export async function determineMigrationNeededV581( cryptoStarknetService: WalletCryptoStarknetService, @@ -76,6 +78,7 @@ export async function migrateAccountsV581( await store.set({ selected: firstValidAccount ? { + id: firstValidAccount.id, address: firstValidAccount.address, networkId: firstValidAccount.networkId, } diff --git a/packages/extension/src/background/miscellaneousMessaging.ts b/packages/extension/src/background/miscellaneousMessaging.ts index cfad21e5b..0ccdf26b9 100644 --- a/packages/extension/src/background/miscellaneousMessaging.ts +++ b/packages/extension/src/background/miscellaneousMessaging.ts @@ -1,10 +1,10 @@ import browser from "webextension-polyfill" -import { MiscenalleousMessage as MiscellaneousMessage } from "../shared/messages/MiscellaneousMessage" +import type { MiscenalleousMessage as MiscellaneousMessage } from "../shared/messages/MiscellaneousMessage" import { resetDevice } from "../shared/smartAccount/jwt" import { sendMessageToUi } from "./activeTabs" import { UnhandledMessage } from "./background" -import { HandleMessage } from "./background" +import type { HandleMessage } from "./background" import { backgroundUIService } from "./services/ui" export const handleMiscellaneousMessage: HandleMessage< diff --git a/packages/extension/src/background/multisig/multisigDeployAction.ts b/packages/extension/src/background/multisig/multisigDeployAction.ts index 717a5ea71..d9ed9354e 100644 --- a/packages/extension/src/background/multisig/multisigDeployAction.ts +++ b/packages/extension/src/background/multisig/multisigDeployAction.ts @@ -1,16 +1,18 @@ -import { num } from "starknet" +import { TransactionType } from "starknet" import { - estimatedFeeToMaxFeeTotal, + estimatedFeeToMaxResourceBounds, getTxVersionFromFeeToken, } from "@argent/x-shared" -import { ExtensionActionItemOfType } from "../../shared/actionQueue/types" +import type { ExtensionActionItemOfType } from "../../shared/actionQueue/types" import { AccountError } from "../../shared/errors/account" import { SessionError } from "../../shared/errors/session" import { addTransaction } from "../../shared/transactions/store" import { checkTransactionHash } from "../../shared/transactions/utils" -import { Wallet } from "../wallet" -import { DeployActionExtra } from "../../shared/actionQueue/schema" +import type { Wallet } from "../wallet" +import type { DeployActionExtra } from "../../shared/actionQueue/schema" +import { getEstimatedFees } from "../../shared/transactionSimulation/fees/estimatedFeesRepository" +import { TransactionError } from "../../shared/errors/transaction" export const addMultisigDeployAction = async ( action: ExtensionActionItemOfType<"DEPLOY_MULTISIG">, @@ -21,7 +23,7 @@ export const addMultisigDeployAction = async ( throw new SessionError({ code: "NO_OPEN_SESSION" }) } const { account: baseAccount } = action.payload - const selectedMultisig = await wallet.getMultisigAccount(baseAccount) + const selectedMultisig = await wallet.getMultisigAccount(baseAccount.id) const multisigNeedsDeploy = selectedMultisig.needsDeploy @@ -36,17 +38,28 @@ export const addMultisigDeployAction = async ( const { feeTokenAddress } = extra const version = getTxVersionFromFeeToken(feeTokenAddress) - // TODO: refactor to use the fee estimation repo - const maxFee = await wallet - .getAccountDeploymentFee(selectedMultisig, feeTokenAddress) - .then(estimatedFeeToMaxFeeTotal) - .catch(() => num.toBigInt(20e14)) + const accountDeployPayload = + await wallet.getMultisigDeploymentPayload(selectedMultisig) - const { account, txHash } = await wallet.deployAccount(selectedMultisig, { - maxFee, - version, + const preComputedFees = await getEstimatedFees({ + type: TransactionType.DEPLOY_ACCOUNT, + payload: accountDeployPayload, }) + if (!preComputedFees) { + throw new TransactionError({ code: "NO_PRE_COMPUTED_FEES" }) + } + + const deployDetails = { + version, + ...estimatedFeeToMaxResourceBounds(preComputedFees.transactions), + } + + const { account, txHash } = await wallet.deployAccount( + selectedMultisig, + deployDetails, + ) + if (!checkTransactionHash(txHash)) { throw Error( "Deploy Multisig Transaction could not be added to the sequencer", diff --git a/packages/extension/src/background/multisig/worker/MultisigWorker.test.ts b/packages/extension/src/background/multisig/worker/MultisigWorker.test.ts new file mode 100644 index 000000000..b10f91ffc --- /dev/null +++ b/packages/extension/src/background/multisig/worker/MultisigWorker.test.ts @@ -0,0 +1,330 @@ +import { addressSchema, isEqualAddress } from "@argent/x-shared" +import type { Mocked } from "vitest" +import { + CHANGE_SIGNER_ACTIVITIES, + SEND_ACTIVITIES, +} from "../../test/__fixtures__/activities" +import type { IAccountService } from "../../../shared/account/service/accountService/IAccountService" +import type { WalletAccountSharedService } from "../../../shared/account/service/accountSharedService/WalletAccountSharedService" +import type { IActivityCacheService } from "../../../shared/activity/cache/IActivityCacheService" +import { getMockDebounceService } from "../../../shared/debounce/mock" +import type { IMultisigBackendService } from "../../../shared/multisig/service/backend/IMultisigBackendService" +import type { INetworkService } from "../../../shared/network/service/INetworkService" +import type { INotificationService } from "../../../shared/notifications/INotificationService" +import { createScheduleServiceMock } from "../../../shared/schedule/mock" +import type { + BaseMultisigWalletAccount, + MultisigWalletAccount, +} from "../../../shared/wallet.model" +import type { IActivityService } from "../../services/activity/IActivityService" +import type { IBackgroundUIService } from "../../services/ui/IBackgroundUIService" +import { SignerType } from "./../../../shared/wallet.model" +import { MultisigWorker } from "./MultisigWorker" +import { MockFnRepository } from "../../../shared/storage/__new/__test__/mockFunctionImplementation" +import { getMockAccount } from "../../../../test/account.mock" +import { + getAccountIdentifier, + getRandomAccountIdentifier, +} from "../../../shared/utils/accountIdentifier" +import { getDerivationPathForIndex } from "../../../shared/signer/utils" +import { stark } from "starknet" + +const mockAccount: MultisigWalletAccount = { + address: "0x2418f74a90c5f8488d011c811a6d40148ca3f3491965cf247fb03a85ba88213", + cairoVersion: "1", + classHash: + "0x06e150953b26271a740bf2b6e9bca17cc52c68d765f761295de51ceb8526ee72", + id: "0x02418f74a90c5f8488d011c811a6d40148ca3f3491965cf247fb03a85ba88213::sepolia-alpha::local_secret::3", + index: 3, + name: "Multisig 3", + needsDeploy: false, + networkId: "sepolia-alpha", + signer: { + derivationPath: "m/44'/9004'/1'/0/3", + type: SignerType.LOCAL_SECRET, + }, + type: "multisig", + network: { + id: "sepolia-alpha", + name: "Sepolia", + chainId: "SN_SEPOLIA", + rpcUrl: "https://api.hydrogen.argent47.net/v1/starknet/sepolia/rpc/v0.7", + explorerUrl: "https://sepolia.voyager.online", + l1ExplorerUrl: "https://sepolia.etherscan.io", + accountClassHash: { + standard: + "0x036078334509b514626504edc9fb252328d1a240e4e948bef8d0c08dff45927f", + standardCairo0: + "0x033434ad846cdd5f23eb73ff09fe6fddd568284a0fb7d1be20ee482f044dabe2", + multisig: + "0x6e150953b26271a740bf2b6e9bca17cc52c68d765f761295de51ceb8526ee72", + smart: + "0x036078334509b514626504edc9fb252328d1a240e4e948bef8d0c08dff45927f", + }, + multicallAddress: + "0x05754af3760f3356da99aea5c3ec39ccac7783d925a19666ebbeca58ff0087f4", + possibleFeeTokenAddresses: [ + "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + "0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d", + ], + readonly: true, + }, + publicKey: + "0x03e72009beaa727fcd904f75a75799b0158fb67269c4fe4ef16029edee49f9bb", + signers: [ + "0x06c0fcbc5948d472c752bd59f229d1e3431bd75a26b7f4532e507c666a5579a0", + "0x0141611519ff946ec55650efff36a1d65c0b253ec39d7742fcf548985294eed0", + ], + threshold: 2, + updatedAt: 1725273484692, +} + +describe("MultisigWorker", () => { + const mockBackgroundUIService = { + emitter: { + on: vi.fn(), + }, + } as unknown as IBackgroundUIService + const mockActivityService = { + emitter: { on: vi.fn() }, + } as unknown as Mocked + const mockActivityCacheService = { + getCachedActivities: vi.fn(), + } as unknown as Mocked + const mockMultisigBackendService = + {} as unknown as Mocked + const mockAccountService = { + get: vi.fn(), + remove: vi.fn(), + } as unknown as Mocked + const mockWalletAccountSharedService = + {} as unknown as Mocked + const mockNetworkService = {} as unknown as Mocked + const mockNotificationService = {} as unknown as Mocked + + const [, _mockScheduleService] = createScheduleServiceMock() + const mockScheduleService = _mockScheduleService + + const mockDebounceService = getMockDebounceService() + + const mockBaseMultisigRepo = new MockFnRepository() + + const multisigMetadataRepo = new MockFnRepository() + + const multisigWorker = new MultisigWorker( + mockBaseMultisigRepo, + multisigMetadataRepo, + mockScheduleService, + mockMultisigBackendService, + mockBackgroundUIService, + mockDebounceService, + mockAccountService, + mockWalletAccountSharedService, + mockNetworkService, + mockNotificationService, + mockActivityCacheService, + mockActivityService, + ) + + describe("findNewSignerInActivity", async () => { + it("should return the pending signer", async () => { + const account = JSON.parse(JSON.stringify(mockAccount)) + account.pendingSigner = { + pubKey: + "0x06c0fcbc5948d472c752bd59f229d1e3431bd75a26b7f4532e507c666a5579a0", + signer: { + derivationPath: "m/2645'/1195502025'/1148870696'/1'/0'/42", + type: SignerType.LEDGER, + }, + } + mockActivityCacheService.getCachedActivities.mockResolvedValue( + CHANGE_SIGNER_ACTIVITIES, + ) + const newSigner = await multisigWorker.findNewSignerInActivity(account) + expect( + isEqualAddress(newSigner, account.pendingSigner?.pubKey), + ).toBeTruthy() + }) + + it("should return undefined if the account has no pending signer", async () => { + mockActivityCacheService.getCachedActivities.mockResolvedValue( + CHANGE_SIGNER_ACTIVITIES, + ) + const newSigner = + await multisigWorker.findNewSignerInActivity(mockAccount) + expect(newSigner).toBeUndefined() + }) + + it("should return undefined if the pending signer does not exist", async () => { + mockActivityCacheService.getCachedActivities.mockResolvedValue( + SEND_ACTIVITIES, + ) + const newSigner = + await multisigWorker.findNewSignerInActivity(mockAccount) + expect(newSigner).toBeUndefined() + }) + }) + + describe("updateBaseMultisigWalletId", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + const defaultNetwork = "sepolia-alpha" + const defaultSignerType = SignerType.LOCAL_SECRET + + const randAddress1 = addressSchema.parse(stark.randomAddress()) + const randAddress2 = addressSchema.parse(stark.randomAddress()) + + const signer1 = { + type: defaultSignerType, + derivationPath: getDerivationPathForIndex( + 1, + defaultSignerType, + "multisig", + ), + } + + const signer2 = { + type: defaultSignerType, + derivationPath: getDerivationPathForIndex( + 2, + defaultSignerType, + "multisig", + ), + } + + const randId1 = getRandomAccountIdentifier( + randAddress1, + defaultNetwork, + signer1, + ) + const randId2 = getRandomAccountIdentifier( + randAddress2, + defaultNetwork, + signer2, + ) + + it("should update base multisig wallets with missing IDs", async () => { + const walletAccounts = [ + getMockAccount({ + id: randId1, + address: randAddress1, + networkId: defaultNetwork, + signer: signer1, + }), + getMockAccount({ + id: randId2, + address: randAddress2, + networkId: defaultNetwork, + signer: signer2, + }), + ] + const baseMultisigs: Omit[] = [ + { + address: randAddress1, + networkId: defaultNetwork, + signers: [], + threshold: 1, + creator: "0x789", + publicKey: "0xabc", + updatedAt: 1234, + index: 1, + }, + { + address: randAddress2, + networkId: defaultNetwork, + signers: [], + threshold: 1, + creator: "0x789", + publicKey: "0xdef", + updatedAt: 5678, + }, + ] + + mockAccountService.get.mockResolvedValue(walletAccounts) + mockBaseMultisigRepo.get.mockResolvedValue(baseMultisigs) + + await multisigWorker.updateBaseMultisigWalletId() + + const expectedUpdatedAccounts: BaseMultisigWalletAccount[] = [ + { + address: randAddress1, + networkId: "sepolia-alpha", + signers: [], + threshold: 1, + creator: "0x789", + publicKey: "0xabc", + updatedAt: 1234, + index: 1, + id: getAccountIdentifier(randAddress1, defaultNetwork, signer1), + }, + { + address: randAddress2, + networkId: defaultNetwork, + signers: [], + threshold: 1, + creator: "0x789", + publicKey: "0xdef", + updatedAt: 5678, + id: randId2, + index: 2, + }, + ] + + expect(mockBaseMultisigRepo.remove).toHaveBeenCalledWith( + expect.any(Function), + ) + expect(mockBaseMultisigRepo.upsert).toHaveBeenCalledWith( + expectedUpdatedAccounts, + ) + }) + + it("should not update base multisig wallets if all have IDs", async () => { + const walletAccounts = [ + getMockAccount({ + id: randId1, + address: randAddress1, + signer: { type: SignerType.LOCAL_SECRET, derivationPath: "m/1/2" }, + }), + getMockAccount({ + id: randId2, + address: randAddress2, + signer: { type: SignerType.LOCAL_SECRET, derivationPath: "m/3/4" }, + }), + ] + const baseMultisigs: BaseMultisigWalletAccount[] = [ + { + id: randId1, + address: randAddress1, + networkId: defaultNetwork, + signers: [], + threshold: 1, + creator: "0x789", + publicKey: "0xabc", + updatedAt: 1234, + index: 2, + }, + { + id: randId2, + address: randAddress2, + networkId: defaultNetwork, + signers: [], + threshold: 1, + creator: "0x789", + publicKey: "0xdef", + updatedAt: 5678, + index: 4, + }, + ] + + mockAccountService.get.mockResolvedValue(walletAccounts) + mockBaseMultisigRepo.get.mockResolvedValue(baseMultisigs) + + await multisigWorker.updateBaseMultisigWalletId() + + expect(mockBaseMultisigRepo.remove).not.toHaveBeenCalled() + expect(mockBaseMultisigRepo.upsert).not.toHaveBeenCalled() + }) + }) +}) diff --git a/packages/extension/src/background/multisig/worker/MultisigWorker.ts b/packages/extension/src/background/multisig/worker/MultisigWorker.ts index d32ab183f..ff13282d3 100644 --- a/packages/extension/src/background/multisig/worker/MultisigWorker.ts +++ b/packages/extension/src/background/multisig/worker/MultisigWorker.ts @@ -1,25 +1,39 @@ -import { getAccountIdentifier, isEqualAddress } from "@argent/x-shared" -import { flatMap, isEmpty, keyBy, partition } from "lodash-es" +import { ensureArray, isEqualAddress } from "@argent/x-shared" +import retry from "async-retry" +import { flatMap, isEmpty, keyBy, omit, partition } from "lodash-es" +import type { WalletAccountSharedService } from "./../../../shared/account/service/accountSharedService/WalletAccountSharedService" +import type { Call } from "starknet" +import { getAccountClassHashFromChain } from "../../../shared/account/details" +import type { IAccountService } from "../../../shared/account/service/accountService/IAccountService" +import type { IActivityCacheService } from "../../../shared/activity/cache/IActivityCacheService" +import { + getNewSignerInReplaceMultisigSignerCall, + isReplaceSelfAsSignerInMultisigCall, +} from "../../../shared/call/changeMultisigSignersCall" import { RefreshIntervalInSeconds } from "../../../shared/config" -import { IDebounceService } from "../../../shared/debounce" +import type { IDebounceService } from "../../../shared/debounce" import { addMultisigPendingOffchainSignatures, getMultisigPendingOffchainSignatures, multisigPendingOffchainSignatureSchema, removeMultisigPendingOffchainSignature, } from "../../../shared/multisig/pendingOffchainSignaturesStore" +import type { MultisigPendingTransaction } from "../../../shared/multisig/pendingTransactionsStore" import { - MultisigPendingTransaction, addToMultisigPendingTransactions, getMultisigPendingTransactions, multisigPendingTransactionToTransaction, removeFromMultisigPendingTransactions, removeRejectedOnChainPendingTransactions, } from "../../../shared/multisig/pendingTransactionsStore" -import { multisigBaseWalletRepo } from "../../../shared/multisig/repository" -import { IMultisigBackendService } from "../../../shared/multisig/service/backend/IMultisigBackendService" -import { BasePendingMultisig } from "../../../shared/multisig/types" +import type { IMultisigMetadataRepository } from "../../../shared/multisig/repository" +import type { IMultisigBackendService } from "../../../shared/multisig/service/backend/IMultisigBackendService" +import type { + IMultisigBaseWalletRepositary, + PendingMultisig, +} from "../../../shared/multisig/types" +import { MultisigEntryPointType } from "../../../shared/multisig/types" import { getMultisigAccountFromBaseWallet, getMultisigAccounts, @@ -33,37 +47,62 @@ import { getAllPendingMultisigs, pendingMultisigToMultisig, } from "../../../shared/multisig/utils/pendingMultisig" -import { INetworkService } from "../../../shared/network/service/INetworkService" -import { INotificationService } from "../../../shared/notifications/INotificationService" -import { IScheduleService } from "../../../shared/schedule/IScheduleService" +import type { INetworkService } from "../../../shared/network/service/INetworkService" +import type { INotificationService } from "../../../shared/notifications/INotificationService" +import type { IScheduleService } from "../../../shared/schedule/IScheduleService" import { routes } from "../../../shared/ui/routes" -import { +import { getAccountIdentifier } from "../../../shared/utils/accountIdentifier" +import { accountsEqual } from "../../../shared/utils/accountsEqual" +import { isArgentAccount } from "../../../shared/utils/isExternalAccount" +import type { + ArgentWalletAccount, BaseMultisigWalletAccount, BaseWalletAccount, MultisigWalletAccount, - WalletAccount, +} from "../../../shared/wallet.model" +import { + SignerType, + accountIdSchema, baseMultisigWalletAccountSchema, } from "../../../shared/wallet.model" -import { IBackgroundUIService } from "../../services/ui/IBackgroundUIService" +import type { IActivityService } from "../../services/activity/IActivityService" +import { MultisigConfigurationUpdatedActivity } from "../../services/activity/IActivityService" +import type { IBackgroundUIService } from "../../services/ui/IBackgroundUIService" import { everyWhenOpen } from "../../services/worker/schedule/decorators" import { pipe } from "../../services/worker/schedule/pipe" -import { getAccountClassHashFromChain } from "../../../shared/account/details" -import { IAccountService } from "../../../shared/account/service/accountService/IAccountService" +import { replaceValueInStorage } from "../../../shared/storage/__new/replaceValueInStorage" +import { + getBaseDerivationPath, + getDerivationPathForIndex, +} from "../../../shared/signer/utils" +import { getIndexForPath } from "../../../shared/utils/derivationPath" +// eslint-disable-next-line @typescript-eslint/no-unused-vars const id = "multisigUpdate" type Id = typeof id export class MultisigWorker { constructor( + private readonly multisigBaseWalletRepo: IMultisigBaseWalletRepositary, + private readonly multisigMetadataRepo: IMultisigMetadataRepository, private readonly scheduleService: IScheduleService, private readonly multisigBackendService: IMultisigBackendService, private readonly backgroundUiService: IBackgroundUIService, private readonly debounceService: IDebounceService, private readonly accountService: IAccountService, + private readonly walletAccountSharedService: WalletAccountSharedService, private networkService: Pick, private readonly notificationService: INotificationService, - ) {} + private readonly activityCacheService: IActivityCacheService, + private readonly activityService: IActivityService, + ) { + // Listen for activities to detect replacing self as signer + this.activityService.emitter.on( + MultisigConfigurationUpdatedActivity, + this.onMultisigSignerChanges.bind(this), + ) + } updateAll = pipe( everyWhenOpen( @@ -76,6 +115,7 @@ export class MultisigWorker { )(async (): Promise => { console.log("Updating multisig data") await Promise.allSettled([ + this.updateBaseMultisigWalletId(), this.updateDataForPendingMultisig(), this.updateDataForAccounts(), this.updateTransactions(), @@ -88,7 +128,7 @@ export class MultisigWorker { // get all base mutlisig accounts const pendingMultisigs = await getAllPendingMultisigs() // Check with backend for any updates - const updater = async (pendingMultisig: BasePendingMultisig) => { + const updater = async (pendingMultisig: PendingMultisig) => { const network = await this.networkService.getById( pendingMultisig.networkId, ) @@ -104,9 +144,19 @@ export class MultisigWorker { return } + const address = content[0].address + const networkId = pendingMultisig.networkId + + const accountId = getAccountIdentifier( + address, + networkId, + pendingMultisig.signer, + ) + const baseMultisig: BaseMultisigWalletAccount = { - address: content[0].address, - networkId: pendingMultisig.networkId, + id: accountId, + address, + networkId, signers: content[0].signers, threshold: content[0].threshold, creator: content[0].creator, @@ -138,23 +188,27 @@ export class MultisigWorker { return baseMultisigWalletAccountSchema.parse(multisigWalletAccount) } - const { address, networkId, publicKey } = multisigWalletAccount + const { id, address, networkId, publicKey, pendingSigner } = + multisigWalletAccount try { const { content } = await this.multisigBackendService.fetchMultisigAccountData({ + id, address, networkId, }) return { ...content, + id, address, networkId, publicKey, updatedAt: Date.now(), + pendingSigner, } - } catch (error) { + } catch { console.log(`Multisig ${address} not yet sync-ed`) return null } @@ -168,7 +222,7 @@ export class MultisigWorker { ) as PromiseFulfilledResult[] // Update the accounts - await multisigBaseWalletRepo.upsert( + await this.multisigBaseWalletRepo.upsert( updatedAccounts.map((result) => result.value), ) } @@ -180,26 +234,24 @@ export class MultisigWorker { const accounts = await this.accountService.get( (acc) => acc.type === "multisig", ) + const argentAccounts = accounts.filter(isArgentAccount) - const accountsWithClassHash = await getAccountClassHashFromChain(accounts) + const accountsWithClassHash = + await getAccountClassHashFromChain(argentAccounts) // Create a map to store accountWithClassHash with key as unique account id. - const accountsWithClassHashMap = keyBy( - accountsWithClassHash, - getAccountIdentifier, - ) + const accountsWithClassHashMap = keyBy(accountsWithClassHash, "id") const updated = accounts .map((account) => { - const id = getAccountIdentifier(account) return !isEqualAddress( - accountsWithClassHashMap[id]?.classHash, + accountsWithClassHashMap[account.id]?.classHash, account.classHash, ) - ? { ...account, ...accountsWithClassHashMap[id] } + ? { ...account, ...accountsWithClassHashMap[account.id] } : undefined }) - .filter((account): account is WalletAccount => !!account) + .filter((account): account is ArgentWalletAccount => !!account) await this.accountService.upsert(updated) } @@ -208,8 +260,15 @@ export class MultisigWorker { // fetch all requests for full multisig accounts const multisigs = await getMultisigAccounts() let localPendingRequests = await getMultisigPendingTransactions() + + // collect the initial id's, this provides a simple way to know which 'new' request id' to notify user about later + const initialLocalPendingRequestIds = localPendingRequests.map( + (localPendingRequest) => localPendingRequest.requestId, + ) + const fetcher = async (multisig: MultisigWalletAccount) => { const account: BaseWalletAccount = { + id: multisig.id, address: multisig.address, networkId: multisig.networkId, } @@ -244,10 +303,12 @@ export class MultisigWorker { ) // Update the state of local pending requests with the state of the request from the backend. Also add new requests - const updatedPendingMultisigTransactions = pendingRequests - .map((request) => { + const updatedPendingMultisigTransactions = + pendingRequests.flatMap((request) => { const localPendingRequest = localPendingRequests.find( - (r) => r.requestId === request.id, + (r) => + accountsEqual(r.account, request.account) && + r.requestId === request.id, ) if (localPendingRequest) { @@ -261,7 +322,7 @@ export class MultisigWorker { } if (!request.transactionHash) { - return + return [] } const multisigTransactionType = getMultisigTransactionType( @@ -291,17 +352,18 @@ export class MultisigWorker { type: multisigTransactionType ?? "INVOKE", } }) - .filter((r): r is MultisigPendingTransaction => r !== undefined) if (updatedPendingMultisigTransactions.length > 0) { // if there are any updated pending transactions, add them to the store - await addToMultisigPendingTransactions( + const updatedPendingMultisigTransactionsToAdd = updatedPendingMultisigTransactions.filter( // we need the SUBMITTED and SUBMITTING transactions only in the remove method, because the reject tx temporary has those states, and we need to know that there's a rejection tx (r) => r.state === "AWAITING_SIGNATURES" || isMultisigTransactionRejectedAndNonceNotConsumed(r.state), - ), + ) + await addToMultisigPendingTransactions( + updatedPendingMultisigTransactionsToAdd, ) // for rejected on-chain transactions the BE keeps both the initial tx and the rejection tx, so we need to filter out the initial ones await removeRejectedOnChainPendingTransactions( @@ -313,22 +375,19 @@ export class MultisigWorker { localPendingRequests = await getMultisigPendingTransactions() // get the updated local pending requests const updatedFulfilledMultisigTransactions: MultisigPendingTransaction[] = - localPendingRequests - .map((request) => { - const fulfilledRequest = fulfilledRequests.find( - (r) => r.id === request.requestId, - ) - if (fulfilledRequest) { - return { - ...request, - requestId: request.requestId, - state: fulfilledRequest.state, - } - } - - return null - }) - .filter((r): r is MultisigPendingTransaction => r !== null) // simplify the filter condition + localPendingRequests.flatMap((request) => { + const fulfilledRequest = fulfilledRequests.find( + (r) => r.id === request.requestId, + ) + if (!fulfilledRequest) { + return [] + } + return { + ...request, + requestId: request.requestId, + state: fulfilledRequest.state, + } + }) // if there are any pending transactions that are fulfilled, remove them from the multisigPendingTransactions store // and add them to the transactions store @@ -343,6 +402,41 @@ export class MultisigWorker { (request) => !allRequests.some((r) => r.id === request.requestId), ) await removeFromMultisigPendingTransactions(localRequestsToRemove) + + // Finally, determine what new requests have actually been added, and notify user + localPendingRequests = await getMultisigPendingTransactions() + const finalLocalPendingRequestIds = localPendingRequests.map( + (localPendingRequest) => localPendingRequest.requestId, + ) + + const newRequestIds = finalLocalPendingRequestIds.filter( + (finalRequestId) => + !initialLocalPendingRequestIds.includes(finalRequestId), + ) + newRequestIds.forEach((newRequestId) => { + const request = localPendingRequests.find( + (request) => request.requestId === newRequestId, + ) + if (!request) { + return // shouldn't happen + } + const multisig = multisigs.find((multisig) => + accountsEqual(request.account, multisig), + ) + if (!multisig) { + return // shouldn't happen + } + // Send notifications only for requests that still need approval + const needsApproval = request.approvedSigners.length < multisig.threshold + if (!needsApproval) { + return + } + this.sendMultisigTransactionNotification( + request.account, + request.transactionHash, + request.requestId, + ) + }) } async updateOffchainSignatures() { @@ -414,7 +508,7 @@ export class MultisigWorker { } sendMultisigAccountReadyNotification(account: BaseWalletAccount) { - const id = `MS:READY:${getAccountIdentifier(account)}` + const id = `MS:READY:${account.id}` this.notificationService.showWithDeepLink( { id, @@ -439,7 +533,7 @@ export class MultisigWorker { id, account, route: routes.multisigPendingTransactionDetails( - account.address, + account.id, requestId, routes.accountActivity(), ), @@ -450,4 +544,224 @@ export class MultisigWorker { }, ) } + + async findNewSignerInActivity(account: MultisigWalletAccount) { + const accountActivities = + await this.activityCacheService.getCachedActivities(account) + for (const activity of ensureArray(accountActivities)) { + const action = activity.actions?.find( + (action) => action.name === "account_multisig_replace_signer", + ) + + const property = action?.defaultProperties?.find( + (property) => + property.type === "calldata" && + property.entrypoint === MultisigEntryPointType.REPLACE_SIGNER && + "calldata" in property, + ) + const replaceSignerCall = { + ...property, + contractAddress: account.address, + } as Call + + if ( + !isReplaceSelfAsSignerInMultisigCall( + replaceSignerCall, + account.publicKey, + ) + ) { + return + } + + const newSigner = + getNewSignerInReplaceMultisigSignerCall(replaceSignerCall) + + if ( + !newSigner || + !isEqualAddress(newSigner, account.pendingSigner?.pubKey) + ) { + return + } + return newSigner + } + } + + private async updateMultisigMetadataWithPendingSigner( + multisigData: MultisigWalletAccount, + ): Promise { + if (!multisigData.pendingSigner) { + return + } + + const [multisigMetadata] = await this.multisigMetadataRepo.get( + (multisigMetadata) => + isEqualAddress( + multisigMetadata.multisigPublicKey, + multisigData.publicKey, + ), + ) + + const newMultisigMetadata = { + multisigPublicKey: multisigData.pendingSigner.pubKey, + signers: + multisigMetadata?.signers.filter( + (signer) => !isEqualAddress(signer.key, multisigData.publicKey), + ) ?? [], + } + + await this.multisigMetadataRepo.upsert(newMultisigMetadata) + await this.multisigMetadataRepo.remove(multisigMetadata) + } + + /** + * Checks for self signer changes. If there are any, it will update the account with the new signer and the multisig account data with the new public key. + * 1. find the activities of type replaceSelfAsSigner + * 2. check if the multisig has the pendingSigner field set + * 3. check if the multisig pendingSigner pub key matches the new signer extracted from the transaction + * 4. update the multisig pub key + * 5. update the account signer + * 6. update the multisig metadata + * 7. replace the account id with the new one everywhere in storage + */ + async onMultisigSignerChanges(accounts: BaseWalletAccount[]) { + for (const account of accounts) { + const multisigData = await getMultisigAccountFromBaseWallet(account) + + if (!multisigData?.pendingSigner) { + return + } + + // needs retry in case the activities list is not updated yet + const newSigner = await retry( + async () => { + const response = await this.findNewSignerInActivity(multisigData) + if (response) { + return response + } else { + throw new Error("Retrying..") + } + }, + { + retries: 5, + minTimeout: 1000, + maxTimeout: 1000, + }, + ) + + if (!newSigner) { + return + } + // update the signers too to avoid showing the 'You were removed from this multisig' screen until the updateDataForAccounts runs again + const oldPubKey = multisigData.publicKey + const newSigners = multisigData.signers.map((signer) => + isEqualAddress(signer, oldPubKey) ? newSigner : signer, + ) + + const accountId = getAccountIdentifier( + multisigData.address, + multisigData.networkId, + multisigData.pendingSigner.signer, + ) + + // we cannot update the account as the id changes. So we add the new one and remove the old + const updatedMultisigData = { + ...multisigData, + id: accountId, + signers: newSigners, + publicKey: multisigData.pendingSigner.pubKey, + pendingSigner: undefined, + } + await this.multisigBaseWalletRepo.upsert(updatedMultisigData) + await this.multisigBaseWalletRepo.remove((account) => + accountsEqual(account, multisigData), + ) + + const [walletAccount] = + await this.accountService.getFromBaseWalletAccounts([account]) + + await this.accountService.upsert({ + ...walletAccount, + id: accountId, + signer: multisigData.pendingSigner.signer, + }) + await this.accountService.removeById(walletAccount.id) + + await this.updateMultisigMetadataWithPendingSigner(multisigData) + + await this.walletAccountSharedService.selectAccount(accountId) + + await replaceValueInStorage(walletAccount.id, accountId, ["id"]) + } + } + // Migration from legacy accounts without IDs to the new ID-based accounts is necessary. + // A similar migration is performed in AccountWorker; however, it is also essential here to update the stored Multisig Data. + // This ensures the construction of complete Multisig Accounts by merging them with WalletAccount records via their IDs. + async updateBaseMultisigWalletId() { + const walletAccounts = await this.accountService.get() + + const walletAccountsMap = keyBy(walletAccounts, "id") + const baseMultisigs = await this.multisigBaseWalletRepo.get() + const baseMultisigsWithoutId = baseMultisigs.filter( + (account) => !accountIdSchema.safeParse(account.id).success, // This is future-proof as we can change the id format in the future + ) + + if (baseMultisigsWithoutId.length === 0) { + return + } + + const supportedSigners = Object.values(omit(SignerType, "PRIVATE_KEY")) // Multisig accounts can't be created with private key signers + const updatedAccountsWithoutId: BaseMultisigWalletAccount[] = [] + + for (const multisig of baseMultisigsWithoutId) { + const { index, address, networkId } = multisig + // Check if index is present + if (index !== undefined) { + signerLoop: for (const signerType of supportedSigners) { + const signer = { + type: signerType, + derivationPath: getDerivationPathForIndex( + index, + signerType, + "multisig", + ), + } + const accountId = getAccountIdentifier(address, networkId, signer) + + // Check if the account exists in the wallet accounts + if (walletAccountsMap[accountId]) { + updatedAccountsWithoutId.push({ + ...multisig, + id: accountId, + }) + break signerLoop + } + } + + // If index is not present, we will have to naively add accountId with the fact + // that only single signer was used in the same multisig wallet instance + } else { + const account = walletAccounts.find( + (acc) => + isEqualAddress(acc.address, multisig.address) && + acc.networkId === multisig.networkId, + ) + if (account) { + updatedAccountsWithoutId.push({ + ...multisig, + id: account.id, + index: getIndexForPath( + account.signer.derivationPath, + getBaseDerivationPath("multisig", account.signer.type), + ), + }) + } + } + } + + await this.multisigBaseWalletRepo.remove( + (acc) => !accountIdSchema.safeParse(acc.id).success, // Need to use selector function instead of values to override the default compare function + ) + + await this.multisigBaseWalletRepo.upsert(updatedAccountsWithoutId) + } } diff --git a/packages/extension/src/background/multisig/worker/index.ts b/packages/extension/src/background/multisig/worker/index.ts index 5203aa2c7..6998624c0 100644 --- a/packages/extension/src/background/multisig/worker/index.ts +++ b/packages/extension/src/background/multisig/worker/index.ts @@ -1,18 +1,32 @@ -import { backgroundUIService } from "../../services/ui" +import { + accountService, + accountSharedService, +} from "../../../shared/account/service" import { debounceService } from "../../../shared/debounce" +import { + multisigBaseWalletRepo, + multisigMetadataRepo, +} from "../../../shared/multisig/repository" import { argentMultisigBackendService } from "../../../shared/multisig/service/backend" import { networkService } from "../../../shared/network/service" import { chromeScheduleService } from "../../../shared/schedule" -import { MultisigWorker } from "./MultisigWorker" +import { activityService } from "../../services/activity" +import { activityCacheService } from "../../services/activity/cache" import { notificationService } from "../../services/notifications" -import { accountService } from "../../../shared/account/service" +import { backgroundUIService } from "../../services/ui" +import { MultisigWorker } from "./MultisigWorker" export const multisigWorker = new MultisigWorker( + multisigBaseWalletRepo, + multisigMetadataRepo, chromeScheduleService, argentMultisigBackendService, backgroundUIService, debounceService, accountService, + accountSharedService, networkService, notificationService, + activityCacheService, + activityService, ) diff --git a/packages/extension/src/background/networkMessaging.ts b/packages/extension/src/background/networkMessaging.ts index 38988a7a4..32a5f8907 100644 --- a/packages/extension/src/background/networkMessaging.ts +++ b/packages/extension/src/background/networkMessaging.ts @@ -1,9 +1,9 @@ import { num, shortString } from "starknet" -import { NetworkMessage } from "../shared/messages/NetworkMessage" +import type { NetworkMessage } from "../shared/messages/NetworkMessage" import { networkService } from "../shared/network/service" import { UnhandledMessage } from "./background" -import { HandleMessage } from "./background" +import type { HandleMessage } from "./background" import { networkSchema } from "../shared/network" export const handleNetworkMessage: HandleMessage = async ({ diff --git a/packages/extension/src/background/nonce.ts b/packages/extension/src/background/nonce.ts deleted file mode 100644 index ccc36cf40..000000000 --- a/packages/extension/src/background/nonce.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { Account, num } from "starknet" -import { argentMultisigBackendService } from "../shared/multisig/service/backend" -import { KeyValueStorage } from "../shared/storage" -import { BaseWalletAccount, WalletAccount } from "../shared/wallet.model" -import { getAccountIdentifier } from "../shared/wallet.service" - -const nonceStore = new KeyValueStorage>( - {}, - { - namespace: "core:nonceManager", - areaName: "session", - }, -) - -export async function getNonce( - account: WalletAccount, - starknetAccount: Account, -): Promise { - const storageAddress = getAccountIdentifier(account) - let nonceBn = BigInt(0) - - try { - const result = await starknetAccount.getNonce() - nonceBn = num.toBigInt(result) - } catch { - console.warn("Onchain getNonce failed, using stored nonce.") - } - - const storedNonce = await nonceStore.get(storageAddress) - - if (account.type === "multisig") { - // Get the pending transactions from BE and take them into account when calculating the nonce - const { content } = - await argentMultisigBackendService.fetchMultisigTransactionRequests({ - address: account.address, - networkId: account.network.id, - }) - - const maxNonce = content - .map((tx) => tx.nonce) - .reduce((a, b) => Math.max(a, b), 0) - - // Increment the nonce by 1 if there are pending transactions - const nonceForPendingTransactions = content.length ? maxNonce + 1 : nonceBn - // If the account is a multisig, we don't want to store the nonce - return num.toHex(nonceForPendingTransactions) - } - - // If there's no nonce stored or the fetched nonce is bigger than the stored one, store the fetched nonce - if (!storedNonce || nonceBn > num.toBigInt(storedNonce)) { - await nonceStore.set(storageAddress, num.toHex(nonceBn)) - } - - // If the stored nonce is greater than the fetched nonce, use the stored nonce - if (storedNonce && num.toBigInt(storedNonce) > nonceBn) { - return num.toHex(storedNonce) - } - - // else return the fetched nonce - return num.toHex(nonceBn) -} - -export async function increaseStoredNonce( - account: BaseWalletAccount, -): Promise { - const storageAddress = getAccountIdentifier(account) - const storedNonce = await nonceStore.get(storageAddress) - if (storedNonce) { - await nonceStore.set( - storageAddress, - num.toHex(num.toBigInt(storedNonce) + num.toBigInt(1)), - ) - } -} - -export async function resetStoredNonce( - account: BaseWalletAccount, -): Promise { - const storageAddress = getAccountIdentifier(account) - await nonceStore.delete(storageAddress) -} diff --git a/packages/extension/src/background/nonceManagement/INonceManagementService.ts b/packages/extension/src/background/nonceManagement/INonceManagementService.ts new file mode 100644 index 000000000..7eea173e9 --- /dev/null +++ b/packages/extension/src/background/nonceManagement/INonceManagementService.ts @@ -0,0 +1,8 @@ +import type { Hex } from "@argent/x-shared" +import type { AccountId } from "../../shared/wallet.model" + +export interface INonceManagementService { + getNonce(account: AccountId): Promise + increaseLocalNonce(account: AccountId): Promise + resetLocalNonce(account: AccountId): Promise +} diff --git a/packages/extension/src/background/nonceManagement/NonceManagementService.test.ts b/packages/extension/src/background/nonceManagement/NonceManagementService.test.ts new file mode 100644 index 000000000..13b7e8890 --- /dev/null +++ b/packages/extension/src/background/nonceManagement/NonceManagementService.test.ts @@ -0,0 +1,147 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" +import { NonceManagementService } from "./NonceManagementService" +import type { AccountId } from "../../shared/wallet.model" +import { AccountError } from "../../shared/errors/account" + +// Mock dependencies +const mockNonceStore = { + get: vi.fn(), + set: vi.fn(), +} + +const mockAccountSharedService = { + getAccount: vi.fn(), +} + +const mockAccountStarknetService = { + getStarknetAccount: vi.fn(), +} + +const mockMultisigBackendService = { + fetchMultisigTransactionRequests: vi.fn(), +} + +describe("NonceManagementService", () => { + let nonceManagementService: NonceManagementService + + beforeEach(() => { + vi.clearAllMocks() + nonceManagementService = new NonceManagementService( + mockNonceStore as any, + mockAccountSharedService as any, + mockAccountStarknetService as any, + mockMultisigBackendService as any, + ) + }) + + describe("getNonce", () => { + it("should throw AccountError if account is not found", async () => { + const accountId = "0x123" as AccountId + mockAccountSharedService.getAccount.mockResolvedValue(null) + + await expect(nonceManagementService.getNonce(accountId)).rejects.toThrow( + AccountError, + ) + }) + + it("should return local nonce if it is greater than or equal to onchain nonce", async () => { + const accountId = "0x123" as AccountId + const localNonce = "0x5" + const onchainNonce = BigInt(4) + + mockAccountSharedService.getAccount.mockResolvedValue({ + type: "standard", + }) + mockNonceStore.get.mockResolvedValue({ + local: { [accountId]: localNonce }, + }) + mockAccountStarknetService.getStarknetAccount.mockResolvedValue({ + getNonce: vi.fn().mockResolvedValue(onchainNonce), + }) + + const result = await nonceManagementService.getNonce(accountId) + expect(result).toBe(localNonce) + }) + + it("should return onchain nonce if local nonce is lower", async () => { + const accountId = "0x123" as AccountId + const localNonce = "0x3" + const onchainNonce = BigInt(4) + + mockAccountSharedService.getAccount.mockResolvedValue({ + type: "standard", + }) + mockNonceStore.get.mockResolvedValue({ + local: { [accountId]: localNonce }, + }) + mockAccountStarknetService.getStarknetAccount.mockResolvedValue({ + getNonce: vi.fn().mockResolvedValue(onchainNonce), + }) + + const result = await nonceManagementService.getNonce(accountId) + expect(result).toBe("0x04") + }) + + it("should handle multisig accounts", async () => { + const accountId = "0x123" as AccountId + const onchainNonce = BigInt(4) + + mockAccountSharedService.getAccount.mockResolvedValue({ + type: "multisig", + }) + mockNonceStore.get.mockResolvedValue({ local: {} }) + mockAccountStarknetService.getStarknetAccount.mockResolvedValue({ + getNonce: vi.fn().mockResolvedValue(onchainNonce), + }) + mockMultisigBackendService.fetchMultisigTransactionRequests.mockResolvedValue( + { + content: [{ nonce: 5 }, { nonce: 6 }], + }, + ) + + const result = await nonceManagementService.getNonce(accountId) + expect(result).toBe("0x07") + }) + }) + + describe("increaseLocalNonce", () => { + it("should increase local nonce by 1", async () => { + const accountId = "0x123" as AccountId + const initialNonce = "0x5" + + mockNonceStore.get.mockResolvedValue({ + local: { [accountId]: initialNonce }, + }) + + await nonceManagementService.increaseLocalNonce(accountId) + + expect(mockNonceStore.set).toHaveBeenCalledWith({ + local: { [accountId]: "0x06" }, + }) + }) + + it("should not increase nonce if no local nonce exists", async () => { + const accountId = "0x123" as AccountId + + mockNonceStore.get.mockResolvedValue({ local: {} }) + + await nonceManagementService.increaseLocalNonce(accountId) + + expect(mockNonceStore.set).not.toHaveBeenCalled() + }) + }) + + describe("resetLocalNonce", () => { + it("should reset local nonce", async () => { + const accountId = "0x123" as AccountId + + mockNonceStore.get.mockResolvedValue({ local: { [accountId]: "0x5" } }) + + await nonceManagementService.resetLocalNonce(accountId) + + expect(mockNonceStore.set).toHaveBeenCalledWith({ + local: {}, + }) + }) + }) +}) diff --git a/packages/extension/src/background/nonceManagement/NonceManagementService.ts b/packages/extension/src/background/nonceManagement/NonceManagementService.ts new file mode 100644 index 000000000..33740ed81 --- /dev/null +++ b/packages/extension/src/background/nonceManagement/NonceManagementService.ts @@ -0,0 +1,107 @@ +import type { BigNumberish } from "starknet" +import { num } from "starknet" +import type { IMultisigBackendService } from "../../shared/multisig/service/backend/IMultisigBackendService" +import type { AccountId, BaseWalletAccount } from "../../shared/wallet.model" +import type { INonceManagementService } from "./INonceManagementService" +import type { IObjectStore } from "../../shared/storage/__new/interface" +import type { NonceMap } from "./store" +import type { WalletAccountStarknetService } from "../wallet/account/WalletAccountStarknetService" +import type { Hex } from "@argent/x-shared" +import { hexSchema } from "@argent/x-shared" +import type { WalletAccountSharedService } from "../../shared/account/service/accountSharedService/WalletAccountSharedService" +import { AccountError } from "../../shared/errors/account" + +export class NonceManagementService implements INonceManagementService { + constructor( + private readonly nonceStore: IObjectStore, + private readonly accountSharedService: WalletAccountSharedService, + private readonly accountStarknetService: WalletAccountStarknetService, + private readonly multisigBackendService: IMultisigBackendService, + ) {} + + async getNonce(accountId: AccountId): Promise { + const account = await this.accountSharedService.getAccount(accountId) + + if (!account) { + throw new AccountError({ code: "NOT_FOUND" }) + } + + const { local } = await this.nonceStore.get() + const localNonce = local[accountId] + const onchainNonce = await this.getOnchainNonce(accountId) + + if (account.type === "multisig") { + return this.getMultisigNonce(account, onchainNonce) + } + + if (localNonce && num.toBigInt(localNonce) >= onchainNonce) { + // If the stored nonce is greater than or equal to the onchain nonce, use the stored nonce + return localNonce + } + + // Otherwise, set the stored nonce to the onchain nonce + // and return the onchain nonce + await this.setNonce(accountId, onchainNonce) + return this.hexifyNonce(onchainNonce) + } + + async increaseLocalNonce(accountId: AccountId): Promise { + const { local } = await this.nonceStore.get() + const currentNonce = local[accountId] + if (currentNonce) { + const newNonce = this.addNonces(currentNonce, 1) + return this.setNonce(accountId, newNonce) + } + } + + async resetLocalNonce(accountId: AccountId): Promise { + const { local } = await this.nonceStore.get() + delete local[accountId] + await this.nonceStore.set({ local }) + } + + private async getMultisigNonce( + account: BaseWalletAccount, + onchainNonce: bigint, + ): Promise { + const { content } = + await this.multisigBackendService.fetchMultisigTransactionRequests( + account, + ) + + if (content.length === 0) { + return this.hexifyNonce(onchainNonce) + } + + const maxNonce = Math.max(...content.map((tx) => tx.nonce)) + return this.hexifyNonce(maxNonce + 1) // Increment the nonce by 1 if there are pending transactions + } + + private async getOnchainNonce(accountId: AccountId): Promise { + try { + const account = + await this.accountStarknetService.getStarknetAccount(accountId) + const result = await account.getNonce() + return num.toBigInt(result) + } catch { + return BigInt(0) + } + } + + private async setNonce(accountId: AccountId, nonce: BigNumberish) { + const { local } = await this.nonceStore.get() + return this.nonceStore.set({ + local: { + ...local, + [accountId]: this.hexifyNonce(nonce), + }, + }) + } + + private addNonces(aNonce: BigNumberish, bNonce: BigNumberish) { + return num.toHex(num.toBigInt(aNonce) + num.toBigInt(bNonce)) + } + private hexifyNonce(nonce: BigNumberish): Hex { + return hexSchema.parse(num.toHex(nonce)) + } +} diff --git a/packages/extension/src/background/nonceManagement/index.ts b/packages/extension/src/background/nonceManagement/index.ts new file mode 100644 index 000000000..ad6f648eb --- /dev/null +++ b/packages/extension/src/background/nonceManagement/index.ts @@ -0,0 +1,12 @@ +import { accountSharedService } from "../../shared/account/service" +import { argentMultisigBackendService } from "../../shared/multisig/service/backend" +import { accountStarknetService } from "../walletSingleton" +import { NonceManagementService } from "./NonceManagementService" +import { nonceStore } from "./store" + +export const nonceManagementService = new NonceManagementService( + nonceStore, + accountSharedService, + accountStarknetService, + argentMultisigBackendService, +) diff --git a/packages/extension/src/background/nonceManagement/store.ts b/packages/extension/src/background/nonceManagement/store.ts new file mode 100644 index 000000000..21528a0fa --- /dev/null +++ b/packages/extension/src/background/nonceManagement/store.ts @@ -0,0 +1,31 @@ +import type { Hex } from "@argent/x-shared" +import { KeyValueStorage } from "../../shared/storage" +import { adaptKeyValue } from "../../shared/storage/__new/keyvalue" +import type { AccountId } from "../../shared/wallet.model" + +export type NonceMap = { + local: Record +} + +/** For future implementation with parallel nonce channels + * +type NonceChannel = number + +type NonceWithChannelsMap = { + [key: AccountId]: { + [key: NonceChannel]: Hex + } +} +*/ + +const nonceKeyValue = new KeyValueStorage( + { + local: {}, + }, + { + namespace: "core:nonceManagerV2", + areaName: "session", + }, +) + +export const nonceStore = adaptKeyValue(nonceKeyValue) diff --git a/packages/extension/src/background/nonceManagement/worker/INonceManagementWorker.ts b/packages/extension/src/background/nonceManagement/worker/INonceManagementWorker.ts new file mode 100644 index 000000000..9520d1d82 --- /dev/null +++ b/packages/extension/src/background/nonceManagement/worker/INonceManagementWorker.ts @@ -0,0 +1,5 @@ +import type { AccountId } from "../../../shared/wallet.model" + +export interface INonceManagementWorker { + refreshLocalNonce(accountId: AccountId): Promise +} diff --git a/packages/extension/src/background/nonceManagement/worker/NonceManagementWorker.test.ts b/packages/extension/src/background/nonceManagement/worker/NonceManagementWorker.test.ts new file mode 100644 index 000000000..683c378c6 --- /dev/null +++ b/packages/extension/src/background/nonceManagement/worker/NonceManagementWorker.test.ts @@ -0,0 +1,138 @@ +import { vi } from "vitest" +import { NonceManagementWorker } from "./NonceManagementWorker" +import type { + IRepository, + StorageChange, +} from "../../../shared/storage/__new/interface" +import type { INonceManagementService } from "../INonceManagementService" +import type { Transaction } from "../../../shared/transactions" +import type { AccountId } from "../../../shared/wallet.model" +import { getChangedStatusTransactions } from "../../../shared/transactions/getChangedStatusTransactions" +import { getTransactionStatus } from "../../../shared/transactions/utils" +import { getMockAccount } from "../../../../test/account.mock" + +vi.mock("../../../shared/transactions/getChangedStatusTransactions", () => ({ + getChangedStatusTransactions: vi.fn(), +})) + +vi.mock("../../../shared/transactions/utils", () => ({ + getTransactionStatus: vi.fn(), +})) + +describe("NonceManagementWorker", () => { + let transactionsRepo: IRepository + let nonceManagementService: INonceManagementService + let worker: NonceManagementWorker + + beforeEach(() => { + transactionsRepo = { + subscribe: vi.fn(), + } as unknown as IRepository + nonceManagementService = { + resetLocalNonce: vi.fn(), + } as unknown as INonceManagementService + worker = new NonceManagementWorker(transactionsRepo, nonceManagementService) + }) + + afterEach(() => { + vi.resetAllMocks() + }) + + describe("refreshLocalNonce", () => { + it("should call resetLocalNonce with the provided accountId", async () => { + const accountId: AccountId = "test-account" + const resetLocalNonceSpy = vi.spyOn( + nonceManagementService, + "resetLocalNonce", + ) + + await worker.refreshLocalNonce(accountId) + + expect(resetLocalNonceSpy).toHaveBeenCalledWith(accountId) + }) + }) + + describe("onTransactionRepoChange", () => { + it("should not call onRejectedTransaction if there are no changed status transactions", () => { + const getChangedStatusTransactionsMock = vi.mocked( + getChangedStatusTransactions, + ) + getChangedStatusTransactionsMock.mockReturnValue([]) + + const onRejectedTransactionSpy = vi.spyOn(worker, "onRejectedTransaction") + const changeSet: StorageChange = { + newValue: [], + oldValue: [], + } + + worker.onTransactionRepoChange(changeSet) + + expect(onRejectedTransactionSpy).not.toHaveBeenCalled() + }) + + it("should call onRejectedTransaction for rejected transactions", () => { + const rejectedTransaction = { + account: getMockAccount(), + } as any + const changedStatusTransactions = [rejectedTransaction] + const getChangedStatusTransactionsMock = vi.mocked( + getChangedStatusTransactions, + ) + const getTransactionStatusMock = vi.mocked(getTransactionStatus) + getChangedStatusTransactionsMock.mockReturnValue( + changedStatusTransactions, + ) + getTransactionStatusMock.mockReturnValue({ finality_status: "REJECTED" }) + + const onRejectedTransactionSpy = vi.spyOn(worker, "onRejectedTransaction") + const changeSet: StorageChange = { + newValue: changedStatusTransactions, + oldValue: [], + } + + worker.onTransactionRepoChange(changeSet) + + expect(onRejectedTransactionSpy).toHaveBeenCalledWith(rejectedTransaction) + }) + + it("should not call onRejectedTransaction for non-rejected transactions", () => { + const nonRejectedTransaction = { + account: getMockAccount(), + } as any + + const changedStatusTransactions = [nonRejectedTransaction] + const getChangedStatusTransactionsMock = vi.mocked( + getChangedStatusTransactions, + ) + const getTransactionStatusMock = vi.mocked(getTransactionStatus) + getChangedStatusTransactionsMock.mockReturnValue( + changedStatusTransactions, + ) + getTransactionStatusMock.mockReturnValue({ + finality_status: "ACCEPTED_ON_L2", + }) + + const onRejectedTransactionSpy = vi.spyOn(worker, "onRejectedTransaction") + const changeSet: StorageChange = { + newValue: changedStatusTransactions, + oldValue: [], + } + + worker.onTransactionRepoChange(changeSet) + + expect(onRejectedTransactionSpy).not.toHaveBeenCalled() + }) + }) + + describe("onRejectedTransaction", () => { + it("should call refreshLocalNonce with the account id of the rejected transaction", async () => { + const accountId: AccountId = "test-account" + const rejectedTransaction = { + account: { id: accountId }, + } as any + const refreshLocalNonceSpy = vi.spyOn(worker, "refreshLocalNonce") + await worker.onRejectedTransaction(rejectedTransaction) + expect(refreshLocalNonceSpy).toHaveBeenCalledWith(accountId) + }) + }) +}) diff --git a/packages/extension/src/background/nonceManagement/worker/NonceManagementWorker.ts b/packages/extension/src/background/nonceManagement/worker/NonceManagementWorker.ts new file mode 100644 index 000000000..4dcfbe626 --- /dev/null +++ b/packages/extension/src/background/nonceManagement/worker/NonceManagementWorker.ts @@ -0,0 +1,45 @@ +import type { + IRepository, + StorageChange, +} from "../../../shared/storage/__new/interface" +import type { INonceManagementWorker } from "./INonceManagementWorker" +import { getChangedStatusTransactions } from "../../../shared/transactions/getChangedStatusTransactions" +import { isEmpty } from "lodash-es" +import type { Transaction } from "../../../shared/transactions" +import type { AccountId } from "../../../shared/wallet.model" +import type { INonceManagementService } from "../INonceManagementService" +import { getTransactionStatus } from "../../../shared/transactions/utils" + +export class NonceManagementWorker implements INonceManagementWorker { + constructor( + private readonly transactionsRepo: IRepository, + private readonly nonceManagementService: INonceManagementService, + ) { + this.transactionsRepo.subscribe(this.onTransactionRepoChange.bind(this)) + } + + async refreshLocalNonce(accountId: AccountId): Promise { + return this.nonceManagementService.resetLocalNonce(accountId) + } + + // @internal Only exposed for testing + onTransactionRepoChange(changeSet: StorageChange) { + const changedStatusTransactions = getChangedStatusTransactions(changeSet) + if (!changedStatusTransactions || isEmpty(changedStatusTransactions)) { + return + } + + for (const tx of changedStatusTransactions) { + const { finality_status } = getTransactionStatus(tx) + + if (finality_status === "REJECTED") { + void this.onRejectedTransaction(tx) + } + } + } + + // @internal Only exposed for testing + async onRejectedTransaction(rejectedTransaction: Transaction) { + void this.refreshLocalNonce(rejectedTransaction.account.id) + } +} diff --git a/packages/extension/src/background/nonceManagement/worker/index.ts b/packages/extension/src/background/nonceManagement/worker/index.ts new file mode 100644 index 000000000..8a28d3d17 --- /dev/null +++ b/packages/extension/src/background/nonceManagement/worker/index.ts @@ -0,0 +1,8 @@ +import { nonceManagementService } from ".." +import { transactionsRepo } from "../../../shared/transactions/store" +import { NonceManagementWorker } from "./NonceManagementWorker" + +export const nonceManagementWorker = new NonceManagementWorker( + transactionsRepo, + nonceManagementService, +) diff --git a/packages/extension/src/background/preAuthorizationMessaging.ts b/packages/extension/src/background/preAuthorizationMessaging.ts index fab10f580..1ed528da8 100644 --- a/packages/extension/src/background/preAuthorizationMessaging.ts +++ b/packages/extension/src/background/preAuthorizationMessaging.ts @@ -1,8 +1,8 @@ import { uiService } from "../shared/ui" -import { PreAuthorisationMessage } from "../shared/messages/PreAuthorisationMessage" +import type { PreAuthorisationMessage } from "../shared/messages/PreAuthorisationMessage" import { Opened, backgroundUIService } from "./services/ui" import { UnhandledMessage } from "./background" -import { HandleMessage } from "./background" +import type { HandleMessage } from "./background" import { preAuthorizationService } from "../shared/preAuthorization" import { respondToHost } from "./respond" diff --git a/packages/extension/src/background/respond.ts b/packages/extension/src/background/respond.ts index 75a9ed840..3bd3f75c7 100644 --- a/packages/extension/src/background/respond.ts +++ b/packages/extension/src/background/respond.ts @@ -1,4 +1,4 @@ -import { MessageType } from "../shared/messages" +import type { MessageType } from "../shared/messages" import { sendMessageToActiveTabsAndUi, sendMessageToHost, diff --git a/packages/extension/src/background/services/account/worker/AccountWorker.test.ts b/packages/extension/src/background/services/account/worker/AccountWorker.test.ts index 4d4bd6abf..3a6cd08a0 100644 --- a/packages/extension/src/background/services/account/worker/AccountWorker.test.ts +++ b/packages/extension/src/background/services/account/worker/AccountWorker.test.ts @@ -1,22 +1,49 @@ import { TXV1_ACCOUNT_CLASS_HASH, addressSchema } from "@argent/x-shared" -import { Mocked, describe, expect, it, vi } from "vitest" +import type { Mocked } from "vitest" +import { describe, expect, it, vi } from "vitest" import { getMockWalletAccount } from "../../../../../test/walletAccount.mock" -import { IAccountService } from "../../../../shared/account/service/accountService/IAccountService" -import { IScheduleService } from "../../../../shared/schedule/IScheduleService" -import { IActivityService } from "../../activity/IActivityService" +import type { IAccountService } from "../../../../shared/account/service/accountService/IAccountService" +import type { IScheduleService } from "../../../../shared/schedule/IScheduleService" +import type { IActivityService } from "../../activity/IActivityService" import { AccountWorker } from "./AccountWorker" +import { mockAccountsWithoutId } from "../../../../../test/accountsWithoutId.mock" +import { accountIdSchema, SignerType } from "../../../../shared/wallet.model" +import { + getAccountIdentifier, + getRandomAccountIdentifier, +} from "../../../../shared/utils/accountIdentifier" +import { stark } from "starknet" +import { getStoreMock } from "../../../../shared/test.utils" +import type { AnalyticsService } from "../../../../shared/analytics/AnalyticsService" vi.mock("../../../../shared/account/details/getAccountCairoVersionFromChain") +const address = stark.randomAddress() + describe("AccountWorker", () => { + let ampliService: Mocked + beforeEach(() => { vi.resetAllMocks() }) const makeService = () => { const accountService = { - get: () => Promise.resolve([getMockWalletAccount({})]), + get: () => + Promise.resolve([ + getMockWalletAccount({ + id: getRandomAccountIdentifier(address), + address, + }), + ]), + getArgentWalletAccounts: () => + Promise.resolve([ + getMockWalletAccount({ + id: getRandomAccountIdentifier(address), + address, + }), + ]), getDeployed: () => Promise.resolve(true), upsert: vi.fn(), remove: vi.fn(), @@ -39,13 +66,22 @@ describe("AccountWorker", () => { }, } as unknown as Mocked + ampliService = { + identify: vi.fn(), + } as unknown as Mocked + + const walletStore = getStoreMock() + const accountWorker = new AccountWorker( + walletStore, accountService, activityService, scheduleService, + ampliService, ) return { + walletStore, accountWorker, accountService, activityService, @@ -54,7 +90,7 @@ describe("AccountWorker", () => { } it("should update all accounts", async () => { - const { accountWorker: worker } = makeService() + const { accountWorker: worker, walletStore } = makeService() const spyUpdateDeployed = vi.spyOn(worker, "updateDeployed") const spyUpdateAccountClassHash = vi.spyOn(worker, "updateAccountClassHash") @@ -63,11 +99,109 @@ describe("AccountWorker", () => { "updateAccountCairoVersion", ) + walletStore.get = vi.fn().mockResolvedValueOnce({}) + await worker.runUpdaterForAllTasks() expect(spyUpdateDeployed).toHaveBeenCalled() expect(spyUpdateAccountClassHash).toHaveBeenCalled() expect(spyUpdateAccountCairoVersion).toHaveBeenCalled() + expect(ampliService.identify).toHaveBeenCalled() + }) + + it("should correctly identify accounts", async () => { + const { accountWorker: worker, accountService, walletStore } = makeService() + + const spyUpdateDeployed = vi.spyOn(worker, "updateDeployed") + const spyUpdateAccountClassHash = vi.spyOn(worker, "updateAccountClassHash") + const spyUpdateAccountCairoVersion = vi.spyOn( + worker, + "updateAccountCairoVersion", + ) + + accountService.get = vi.fn().mockResolvedValue([ + getMockWalletAccount({ + address: "0x0000000000000000000000000000000000000000000000000", + type: "standard", + networkId: "mainnet-alpha", + }), + getMockWalletAccount({ + address: "0x0000000000000000000000000000000000000000000000000", + type: "standard", + networkId: "sepolia-alpha", + }), + getMockWalletAccount({ + address: "0x0000000000000000000000000000000000000000000000000", + type: "smart", + networkId: "mainnet-alpha", + }), + getMockWalletAccount({ + address: "0x0000000000000000000000000000000000000000000000000", + type: "smart", + networkId: "sepolia-alpha", + }), + getMockWalletAccount({ + address: "0x0000000000000000000000000000000000000000000000000", + type: "multisig", + networkId: "mainnet-alpha", + }), + getMockWalletAccount({ + address: "0x0000000000000000000000000000000000000000000000000", + type: "multisig", + networkId: "sepolia-alpha", + }), + getMockWalletAccount({ + address: "0x0000000000000000000000000000000000000000000000000", + type: "standard", + signer: { + type: SignerType.LEDGER, + derivationPath: "m/44'/9004'/0'/0/0", + }, + networkId: "mainnet-alpha", + }), + getMockWalletAccount({ + address: "0x0000000000000000000000000000000000000000000000000", + type: "standard", + signer: { + type: SignerType.LEDGER, + derivationPath: "m/44'/9004'/0'/0/0", + } as any, + networkId: "sepolia-alpha", + }), + getMockWalletAccount({ + address: "0x0000000000000000000000000000000000000000000000000", + type: "multisig", + signer: { + type: SignerType.LEDGER, + derivationPath: "m/44'/9004'/0'/0/0", + } as any, + networkId: "mainnet-alpha", + }), + getMockWalletAccount({ + address: "0x0000000000000000000000000000000000000000000000000", + type: "multisig", + signer: { + type: SignerType.LEDGER, + derivationPath: "m/44'/9004'/0'/0/0", + } as any, + networkId: "sepolia-alpha", + }), + ]) + + walletStore.get = vi.fn().mockResolvedValueOnce({}) + + await worker.runUpdaterForAllTasks() + + expect(spyUpdateDeployed).toHaveBeenCalled() + expect(spyUpdateAccountClassHash).toHaveBeenCalled() + expect(spyUpdateAccountCairoVersion).toHaveBeenCalled() + expect(ampliService.identify).toHaveBeenCalledWith(undefined, { + "ArgentX Ledger Accounts Count": 2, + "ArgentX Multisig Accounts Count": 2, + "ArgentX Smart Accounts Count": 1, + "ArgentX Standard Accounts Count": 2, + "ArgentX Testnet Accounts Count": 5, + }) }) it("should update account class hash", async () => { @@ -119,7 +253,9 @@ describe("AccountWorker", () => { const mockAccount = getMockWalletAccount({ classHash: undefined }) - accountService.get = vi.fn().mockResolvedValueOnce([mockAccount]) + accountService.getArgentWalletAccounts = vi + .fn() + .mockResolvedValueOnce([mockAccount]) await worker.updateAccountClassHash() @@ -136,7 +272,9 @@ describe("AccountWorker", () => { const mockAccount = getMockWalletAccount({ cairoVersion: undefined }) - accountService.get = vi.fn().mockResolvedValueOnce([mockAccount]) + accountService.getArgentWalletAccounts = vi + .fn() + .mockResolvedValueOnce([mockAccount]) // don't know why this works, but it does // https://stackoverflow.com/a/74490815 @@ -159,4 +297,86 @@ describe("AccountWorker", () => { }, ]) }) + + it("should call upsert in updateAccountId with correct parameters", async () => { + const { accountWorker: worker, accountService, walletStore } = makeService() + + accountService.get = vi.fn().mockResolvedValueOnce(mockAccountsWithoutId) + + accountService.remove = vi.fn() + walletStore.get = vi.fn().mockResolvedValueOnce({ + selectedAccount: { + id: getRandomAccountIdentifier(address), + address: mockAccountsWithoutId[0].address, + networkId: mockAccountsWithoutId[0].networkId, + }, + }) + + await worker.updateAccountId() + + expect(accountService.upsert).toHaveBeenCalledWith( + mockAccountsWithoutId.map((account) => ({ + ...account, + id: accountIdSchema.parse( + getAccountIdentifier( + account.address, + account.networkId, + account.signer as any, + ), + ), + })), + ) + + // Ensure updateSelectedAccount is called to handle the selected account update + expect(walletStore.get).toHaveBeenCalled() + }) + + it("should call upsert in walletStore with correct parameters on updateAccountId", async () => { + const { accountWorker: worker, accountService, walletStore } = makeService() + + const randomId1 = getRandomAccountIdentifier( + mockAccountsWithoutId[0].address, + ) + const randomId2 = getRandomAccountIdentifier( + mockAccountsWithoutId[1].address, + ) + + accountService.get = vi.fn().mockReturnValue([ + { ...mockAccountsWithoutId[0], id: randomId1 }, + { + ...mockAccountsWithoutId[1], + id: randomId2, + networkId: "mainnet-alpha", + }, + ]) + + walletStore.get = vi.fn().mockResolvedValueOnce({ + lastUsedAccountByNetwork: { + "sepolia-alpha": { + ...mockAccountsWithoutId[0], + }, + "mainnet-alpha": { + address: mockAccountsWithoutId[1].address, + networkId: "mainnet-alpha", + }, + }, + }) + + await worker.updateAccountId() + + expect(walletStore.set).toHaveBeenCalledWith({ + lastUsedAccountByNetwork: { + "sepolia-alpha": { + address: mockAccountsWithoutId[0].address, + networkId: "sepolia-alpha", + id: randomId1, + }, + "mainnet-alpha": { + address: mockAccountsWithoutId[1].address, + networkId: "mainnet-alpha", + id: randomId2, + }, + }, + }) + }) }) diff --git a/packages/extension/src/background/services/account/worker/AccountWorker.ts b/packages/extension/src/background/services/account/worker/AccountWorker.ts index 99155fa42..ea04b6fbd 100644 --- a/packages/extension/src/background/services/account/worker/AccountWorker.ts +++ b/packages/extension/src/background/services/account/worker/AccountWorker.ts @@ -1,34 +1,45 @@ -import { getAccountIdentifier } from "@argent/x-shared" -import { IScheduleService } from "../../../../shared/schedule/IScheduleService" -import { +import type { IScheduleService } from "../../../../shared/schedule/IScheduleService" +import type { ArgentAccountType, + ArgentWalletAccount, + BaseWalletAccount, + NetworkOnlyPlaceholderAccount, WalletAccount, + WalletAccountType, } from "../../../../shared/wallet.model" +import { accountIdSchema } from "../../../../shared/wallet.model" import { getAccountClassHashFromChain } from "../../../../shared/account/details/getAccountClassHashFromChain" import { getAccountCairoVersionFromChain } from "../../../../shared/account/details/getAccountCairoVersionFromChain" -import { IAccountService } from "../../../../shared/account/service/accountService/IAccountService" +import type { IAccountService } from "../../../../shared/account/service/accountService/IAccountService" import { keyBy } from "lodash-es" import { onInstallAndUpgrade } from "../../worker/schedule/decorators" -import { AllowArray } from "../../../../shared/storage/__new/interface" +import type { AllowArray } from "../../../../shared/storage/__new/interface" import { pipe } from "../../worker/schedule/pipe" import { getOwnerForAccount } from "../../../../shared/account/details/getOwner" +import type { + AccountActivityPayload, + IActivityService, +} from "../../activity/IActivityService" import { + AccountDeployActivity, GuardianChangedActivity, - IActivityService, - AccountActivityPayload, SignerChangedActivity, - AccountDeployActivity, } from "../../activity/IActivityService" import { getGuardianForAccount } from "../../../../shared/account/details/getGuardian" +import { getAccountIdentifier } from "../../../../shared/utils/accountIdentifier" +import type { IWalletStore } from "../../../../shared/wallet/walletStore" +import { isEqualAddress } from "@argent/x-shared" +import { filterArgentAccounts } from "../../../../shared/utils/isExternalAccount" +import type { AnalyticsService } from "../../../../shared/analytics/AnalyticsService" export enum AccountUpdaterTaskId { - UPDATE_DEPLOYED = "accountUpdateDeployed", ACCOUNT_UPDATE_ON_STARTUP = "accountUpdateOnStartup", ACCOUNT_UPDATE_ON_INSTALL_AND_UPGRADE = "accountUpdateOnInstallAndUpgrade", } enum AccountUpdaterTask { + UPDATE_ACCOUNT_ID, UPDATE_DEPLOYED, UPDATE_ACCOUNT_CLASS_HASH, UPDATE_ACCOUNT_CAIRO_VERSION, @@ -37,9 +48,11 @@ enum AccountUpdaterTask { export class AccountWorker { constructor( + private readonly walletStore: IWalletStore, private readonly accountService: IAccountService, private readonly activityService: IActivityService, private readonly scheduleService: IScheduleService, + private readonly ampli: AnalyticsService, ) { this.activityService.emitter.on( SignerChangedActivity, @@ -55,15 +68,47 @@ export class AccountWorker { ) } + async updateAccountUserProperties() { + const accounts = await this.accountService.get() + + // count the number of accounts by type + const counts = accounts.reduce<{ + [Key in WalletAccountType | "ledger" | "testnet"]?: number + }>( + (acc, account) => { + if (account.networkId === "sepolia-alpha") { + acc.testnet = (acc.testnet || 0) + 1 + } else if (account.networkId === "mainnet-alpha") { + acc[account.type] = (acc[account.type] || 0) + 1 + if (account.signer.type === "ledger") { + acc.ledger = (acc.ledger || 0) + 1 + } + } + return acc + }, + { standard: 0, smart: 0, multisig: 0, ledger: 0, testnet: 0 }, + ) + + void this.ampli.identify(undefined, { + "ArgentX Standard Accounts Count": counts.standard, + "ArgentX Smart Accounts Count": counts.smart, + "ArgentX Ledger Accounts Count": counts.ledger, + "ArgentX Multisig Accounts Count": counts.multisig, + "ArgentX Testnet Accounts Count": counts.testnet, + }) + } + runUpdaterForAllTasks = pipe( onInstallAndUpgrade(this.scheduleService), // This will run the function on install and upgrade )(async (): Promise => { await this.runUpdaterTask([ + AccountUpdaterTask.UPDATE_ACCOUNT_ID, AccountUpdaterTask.UPDATE_DEPLOYED, AccountUpdaterTask.UPDATE_ACCOUNT_CLASS_HASH, AccountUpdaterTask.UPDATE_ACCOUNT_CAIRO_VERSION, AccountUpdaterTask.UPDATE_ACCOUNT_GUARDIAN, ]) + await this.updateAccountUserProperties() }) /** @internal just exposed for testing */ @@ -71,6 +116,9 @@ export class AccountWorker { const updaterTasks = Array.isArray(tasks) ? tasks : [tasks] for (const task of updaterTasks) { switch (task) { + case AccountUpdaterTask.UPDATE_ACCOUNT_ID: + await this.updateAccountId() + break case AccountUpdaterTask.UPDATE_DEPLOYED: await this.updateDeployed() break @@ -116,20 +164,15 @@ export class AccountWorker { /** @internal just exposed for testing */ async updateAccountClassHash(): Promise { - const accounts = await this.accountService.get() - + const accounts = await this.accountService.getArgentWalletAccounts() const accountsWithClassHash = await getAccountClassHashFromChain(accounts) // Create a map to store accountWithClassHash with key as unique account id. - const accountsWithClassHashMap = keyBy( - accountsWithClassHash, - getAccountIdentifier, - ) + const accountsWithClassHashMap = keyBy(accountsWithClassHash, "id") const updated = accounts.map((account) => { - const id = getAccountIdentifier(account) - return accountsWithClassHashMap[id] - ? { ...account, ...accountsWithClassHashMap[id] } + return accountsWithClassHashMap[account.id] + ? { ...account, ...accountsWithClassHashMap[account.id] } : account }) @@ -138,21 +181,17 @@ export class AccountWorker { /** @internal just exposed for testing */ async updateAccountCairoVersion(): Promise { - const accounts = await this.accountService.get() + const accounts = await this.accountService.getArgentWalletAccounts() const accountsWithCairoVersion = await getAccountCairoVersionFromChain(accounts) // Create a map to store accountWithCairoVersion with key as unique account id. - const accountsWithCairoVersionMap = keyBy( - accountsWithCairoVersion, - getAccountIdentifier, - ) + const accountsWithCairoVersionMap = keyBy(accountsWithCairoVersion, "id") const updated = accounts.map((account) => { - const id = getAccountIdentifier(account) - return accountsWithCairoVersionMap[id] - ? { ...account, ...accountsWithCairoVersionMap[id] } + return accountsWithCairoVersionMap[account.id] + ? { ...account, ...accountsWithCairoVersionMap[account.id] } : account }) @@ -160,8 +199,7 @@ export class AccountWorker { } async updateAccountGuardian() { - const accounts = await this.accountService.get() - + const accounts = await this.accountService.getArgentWalletAccounts() await this.updateGuardianForAccounts(accounts) } @@ -187,22 +225,108 @@ export class AccountWorker { async onGuardianChanged(payload: AccountActivityPayload) { const accounts = await this.accountService.getFromBaseWalletAccounts(payload) - await this.updateGuardianForAccounts(accounts) + await this.updateGuardianForAccounts(filterArgentAccounts(accounts)) } - private async updateGuardianForAccounts(accounts: WalletAccount[]) { + async updateAccountId() { + const accounts = await this.accountService.get() + + const accountsWithoutId = accounts.filter( + (account) => !accountIdSchema.safeParse(account.id).success, // This is future-proof as we can change the id format in the future + ) + + if (accountsWithoutId.length === 0) { + await this.updateSelectedAccount() + return + } + + const updatedAccountsWithoutId = accountsWithoutId.map((acc) => ({ + ...acc, + id: getAccountIdentifier(acc.address, acc.networkId, acc.signer), + })) + + await this.accountService.remove( + (acc) => !accountIdSchema.safeParse(acc.id).success, // Need to use selector function instead of values to override the default compare function + ) + + await this.accountService.upsert(updatedAccountsWithoutId) + await this.updateSelectedAccount() + } + + private async getAccountWithId( + account: BaseWalletAccount | NetworkOnlyPlaceholderAccount | null, + ) { + if (!account || account.id) { + return account + } + + const accountsOnNetwork = await this.accountService.get( + (acc) => acc.networkId === account.networkId, + ) + + const fullSelectedAccount = accountsOnNetwork.find( + (acc) => account.address && isEqualAddress(acc.address, account.address), + ) + + const { id, address, networkId } = + fullSelectedAccount ?? accountsOnNetwork[0] + + return { + id, + address, + networkId, + } + } + + async updateSelectedAccount() { + const { selected, lastUsedAccountByNetwork } = await this.walletStore.get() + + let updatedSelected + if (selected && !selected.id) { + updatedSelected = await this.getAccountWithId(selected) + } + + const updatedLastUsedAccountByNetwork: Record = + {} + + for (const networkId in lastUsedAccountByNetwork) { + const account = lastUsedAccountByNetwork[networkId] + const accountWithId = await this.getAccountWithId(account) + + if (accountWithId) { + updatedLastUsedAccountByNetwork[networkId] = accountWithId + } + } + + await this.walletStore.set({ + ...(updatedSelected && { selected: updatedSelected }), + lastUsedAccountByNetwork: { + ...lastUsedAccountByNetwork, + ...updatedLastUsedAccountByNetwork, + }, + }) + } + + private async updateGuardianForAccounts(accounts: ArgentWalletAccount[]) { const results = await Promise.allSettled( accounts .filter((account) => !account.needsDeploy) - .map((account) => { - return getGuardianForAccount(account) - }), + .map(async (account) => ({ + address: account.address, + guardian: await getGuardianForAccount(account), + })), ) - const updated = accounts.map((account, index) => { - const result = results[index] - const onChainGuardian = - result?.status === "fulfilled" ? result?.value : undefined + const updated = accounts.map((account) => { + const result = results + .filter((r) => r.status === "fulfilled") + .find((r) => isEqualAddress(r.value.address, account.address)) + + if (!result) { + return { ...account } + } + + const onChainGuardian = result.value.guardian const guardian = account.needsDeploy && account.guardian ? account.guardian @@ -222,6 +346,7 @@ export class AccountWorker { ...(updatedType ? { type: updatedType } : {}), } }) + await this.accountService.upsert(updated) } } diff --git a/packages/extension/src/background/services/account/worker/index.ts b/packages/extension/src/background/services/account/worker/index.ts index ddf7d4905..94c8492af 100644 --- a/packages/extension/src/background/services/account/worker/index.ts +++ b/packages/extension/src/background/services/account/worker/index.ts @@ -2,9 +2,13 @@ import { chromeScheduleService } from "../../../../shared/schedule" import { accountService } from "../../../../shared/account/service" import { AccountWorker } from "./AccountWorker" import { activityService } from "../../activity" +import { walletStore } from "../../../../shared/wallet/walletStore" +import { ampli } from "../../../../shared/analytics" export const accountWorker = new AccountWorker( + walletStore, accountService, activityService, chromeScheduleService, + ampli, ) diff --git a/packages/extension/src/background/services/action/BackgroundActionService.ts b/packages/extension/src/background/services/action/BackgroundActionService.ts index 2e273d89b..a7ec0508b 100644 --- a/packages/extension/src/background/services/action/BackgroundActionService.ts +++ b/packages/extension/src/background/services/action/BackgroundActionService.ts @@ -1,23 +1,23 @@ import { isObject, isString } from "lodash-es" -import { IActionQueue } from "../../../shared/actionQueue/queue/IActionQueue" +import type { IActionQueue } from "../../../shared/actionQueue/queue/IActionQueue" import type { ActionHash, ActionItemExtra, ActionQueueItemMeta, } from "../../../shared/actionQueue/schema" -import { +import type { ActionItem, ExtensionActionItem, ExtQueueItem, } from "../../../shared/actionQueue/types" -import { MessageType } from "../../../shared/messages" +import type { MessageType } from "../../../shared/messages" import { handleActionApproval, handleActionRejection, } from "../../actionHandlers" import type { Respond, RespondToHost } from "../../respond" -import { Wallet } from "../../wallet" +import type { Wallet } from "../../wallet" import { TransactionCreatedForAction, type Events, diff --git a/packages/extension/src/background/services/action/IBackgroundActionService.ts b/packages/extension/src/background/services/action/IBackgroundActionService.ts index ca4fd651e..bc1a3c1f3 100644 --- a/packages/extension/src/background/services/action/IBackgroundActionService.ts +++ b/packages/extension/src/background/services/action/IBackgroundActionService.ts @@ -1,5 +1,5 @@ import type Emittery from "emittery" -import { +import type { ActionHash, ActionQueueItemMeta, } from "../../../shared/actionQueue/schema" diff --git a/packages/extension/src/background/services/activity/ActivityService.test.ts b/packages/extension/src/background/services/activity/ActivityService.test.ts index 99db9eac8..e1fec12b4 100644 --- a/packages/extension/src/background/services/activity/ActivityService.test.ts +++ b/packages/extension/src/background/services/activity/ActivityService.test.ts @@ -1,7 +1,8 @@ -import { Mocked, describe, expect, test, vi } from "vitest" +import type { Mocked } from "vitest" +import { describe, expect, test, vi } from "vitest" -import { IHttpService, Token } from "@argent/x-shared" -import Emittery from "emittery" +import type { IHttpService, Token } from "@argent/x-shared" +import type Emittery from "emittery" import type { IAccountService } from "../../../shared/account/service/accountService/IAccountService" import type { IActivityStorage } from "../../../shared/activity/types" @@ -22,6 +23,7 @@ import type { IBackgroundUIService } from "../ui/IBackgroundUIService" import { ActivityService } from "./ActivityService" import { GuardianChangedActivity, + MultisigConfigurationUpdatedActivity, NewTokenActivity, NftActivity, type Events, @@ -29,7 +31,8 @@ import { import activities from "../../../shared/activity/__fixtures__/activities.json" import state from "../../../shared/activity/__fixtures__/state.json" -import { WalletStorageProps } from "../../wallet/backup/WalletBackupService" +import type { WalletStorageProps } from "../../wallet/backup/WalletBackupService" +import { getRandomAccountIdentifier } from "../../../shared/utils/accountIdentifier" describe("ActivityService", () => { const makeService = () => { @@ -121,9 +124,12 @@ describe("ActivityService", () => { const networkId = "sepolia-alpha" + const address = + "0x05f1f0a38429dcab9ffd8a786c0d827e84c1cbd8f60243e6d25d066a13af4a25" + walletSingleton.getSelectedAccount.mockResolvedValue({ - address: - "0x05f1f0a38429dcab9ffd8a786c0d827e84c1cbd8f60243e6d25d066a13af4a25", + id: getRandomAccountIdentifier(address), + address, networkId, } as WalletAccount) @@ -208,9 +214,20 @@ describe("ActivityService", () => { }, ]) + expect(emitter.emit).toHaveBeenCalledWith( + MultisigConfigurationUpdatedActivity, + [ + { + address: + "0x5f1f0a38429dcab9ffd8a786c0d827e84c1cbd8f60243e6d25d066a13af4a25", + networkId: "sepolia-alpha", + }, + ], + ) + expect(activityStoreSetSpy).toHaveBeenLastCalledWith({ modifiedAfter: { - "sepolia-alpha::0x05f1f0a38429dcab9ffd8a786c0d827e84c1cbd8f60243e6d25d066a13af4a25": 1701964096841, + "0x05f1f0a38429dcab9ffd8a786c0d827e84c1cbd8f60243e6d25d066a13af4a25::sepolia-alpha::local_secret::0": 1701964096841, }, }) diff --git a/packages/extension/src/background/services/activity/ActivityService.ts b/packages/extension/src/background/services/activity/ActivityService.ts index 3f1c7162e..10b34ef2a 100644 --- a/packages/extension/src/background/services/activity/ActivityService.ts +++ b/packages/extension/src/background/services/activity/ActivityService.ts @@ -1,6 +1,4 @@ -import { map } from "rxjs/operators" import { - getAccountIdentifier, includesAddress, stripAddressZeroPadding, type Address, @@ -57,7 +55,7 @@ import { } from "@argent/x-shared/simulation" import type { IKeyValueStorage } from "../../../shared/storage" import type { WalletStorageProps } from "../../wallet/backup/WalletBackupService" -import { BaseToken } from "../../../shared/token/__new/types/token.model" +import type { BaseToken } from "../../../shared/token/__new/types/token.model" /** maps activity details action to an equivalent Event to emit */ @@ -125,6 +123,8 @@ export class ActivityService implements IActivityService { async fetchAccountActivities( account?: BaseWalletAccount, updateModifiedAfter = true, + tokenAddress?: Address, + alwaysFetch = false, ) { const apiBaseUrl = ARGENT_API_BASE_URL if (!account || !apiBaseUrl) { @@ -134,7 +134,17 @@ export class ActivityService implements IActivityService { if (!argentApiNetwork) { return } - const modifiedAfter = (await this.getModifiedAfter(account)) ?? 0 + const modifiedAfter = alwaysFetch + ? 0 + : ((await this.getModifiedAfter(account)) ?? 0) + + // Prepare the query parameters + const queryParams: Record = { modifiedAfter } + if (tokenAddress) { + queryParams.relatedAddress = stripAddressZeroPadding(tokenAddress) + } + + // Construct the URL with the query parameters const url = urlWithQuery( [ apiBaseUrl, @@ -145,10 +155,9 @@ export class ActivityService implements IActivityService { stripAddressZeroPadding(account.address), "activities", ], - { - modifiedAfter, - }, + queryParams, ) + const response = await this.httpService.get(url) if (!response) { throw new ActivityError({ code: "FETCH_FAILED" }) @@ -161,7 +170,7 @@ export class ActivityService implements IActivityService { await this.setModifiedAfter(account, overallLastModified) } } - return activities + return activities.sort((a, b) => b.submitted - a.submitted) } async processAndEmitActivities({ @@ -196,10 +205,6 @@ export class ActivityService implements IActivityService { const tokenAddressesOnNetwork = tokensOnNetwork.map( (token) => token.address, ) - const nftAddressesOnNetwork = nftsOnNetwork.map( - (nft) => nft.contractAddress as Address, - ) - /** ignore any "failure" */ const filteredActivities = activities.filter( @@ -215,7 +220,6 @@ export class ActivityService implements IActivityService { activities: filteredActivities, accountAddressesOnNetwork, tokenAddressesOnNetwork, - nftAddressesOnNetwork, }) /** rehydrate the assets and accounts - these are already filtered to same network */ @@ -227,13 +231,6 @@ export class ActivityService implements IActivityService { includesAddress(token.address, tokenActivity.tokenAddresses), ) - const nftAccounts = accountsOnNetwork.filter((account) => - includesAddress(account.address, nftActivity.accountAddresses), - ) - const nfts = nftsOnNetwork.filter((token) => - includesAddress(token.contractAddress, nftActivity.tokenAddresses), - ) - const newTokens: BaseToken[] = tokenActivity.newTokenAddresses.map( (address: Address) => ({ address, @@ -248,7 +245,14 @@ export class ActivityService implements IActivityService { }) } - if (nftAccounts.length && nfts.length) { + if (nftActivity.tokenAddresses.length) { + const nftAccounts = accountsOnNetwork.filter((account) => + includesAddress(account.address, nftActivity.accountAddresses), + ) + const nfts = nftsOnNetwork.filter((token) => + includesAddress(token.contractAddress, nftActivity.tokenAddresses), + ) + void this.emitter.emit(NftActivity, { accounts: nftAccounts, nfts, @@ -292,14 +296,12 @@ export class ActivityService implements IActivityService { account: BaseWalletAccount, ): Promise { const { modifiedAfter } = await this.activityStore.get() - const key = getAccountIdentifier(account) - return modifiedAfter[key] + return modifiedAfter[account.id] } async setModifiedAfter(account: BaseWalletAccount, value: number) { const { modifiedAfter } = await this.activityStore.get() - const key = getAccountIdentifier(account) - modifiedAfter[key] = value + modifiedAfter[account.id] = value await this.activityStore.set({ modifiedAfter }) } } diff --git a/packages/extension/src/background/services/activity/IActivityService.ts b/packages/extension/src/background/services/activity/IActivityService.ts index 685202e28..fa9278e70 100644 --- a/packages/extension/src/background/services/activity/IActivityService.ts +++ b/packages/extension/src/background/services/activity/IActivityService.ts @@ -4,7 +4,7 @@ import type Emittery from "emittery" import type { Activity } from "@argent/x-shared/simulation" import type { ActivitiesPayload } from "../../../shared/activity/types" import type { ContractAddress } from "../../../shared/nft/store" -import { BaseToken } from "../../../shared/token/__new/types/token.model" +import type { BaseToken } from "../../../shared/token/__new/types/token.model" import type { BaseWalletAccount } from "../../../shared/wallet.model" /** raw */ diff --git a/packages/extension/src/background/services/activity/cache/ActivityCacheService.test.ts b/packages/extension/src/background/services/activity/cache/ActivityCacheService.test.ts index 731be7bce..46aca6cf5 100644 --- a/packages/extension/src/background/services/activity/cache/ActivityCacheService.test.ts +++ b/packages/extension/src/background/services/activity/cache/ActivityCacheService.test.ts @@ -13,19 +13,38 @@ import type { IActivityCacheItem, IActivityCacheStorage, } from "../../../../shared/activity/cache/IActivityCacheStorage" -import type { BaseWalletAccount } from "../../../../shared/wallet.model" +import { + SignerType, + type BaseWalletAccount, +} from "../../../../shared/wallet.model" import type { IBackgroundUIService } from "../../ui/IBackgroundUIService" import { sortActivities } from "./mergeAndSortActivities" +import { getAccountIdentifier } from "../../../../shared/utils/accountIdentifier" const networkId = "sepolia-alpha" +const address1 = + "0x05f1f0a38429dcab9ffd8a786c0d827e84c1cbd8f60243e6d25d066a13af4a25" + +const address2 = + "0x05f1f0a38429dcab9ffd8a786c0d827e84c1cbd8f60243e6d25d066a13af4a26" + +const mockSigner = { + type: SignerType.LOCAL_SECRET, + derivationPath: "m/44'/60'/0'/0/0", +} + +const id1 = getAccountIdentifier(address1, networkId, mockSigner) +const id2 = getAccountIdentifier(address2, networkId, mockSigner) const account: BaseWalletAccount = { - address: "0x05f1f0a38429dcab9ffd8a786c0d827e84c1cbd8f60243e6d25d066a13af4a25", + id: id1, + address: address1, networkId, } const otherAccount: BaseWalletAccount = { - address: "0x05f1f0a38429dcab9ffd8a786c0d827e84c1cbd8f60243e6d25d066a13af4a26", + id: id2, + address: address2, networkId, } diff --git a/packages/extension/src/background/services/activity/cache/ActivityCacheService.ts b/packages/extension/src/background/services/activity/cache/ActivityCacheService.ts index 20cf6b6b2..aee81b5b7 100644 --- a/packages/extension/src/background/services/activity/cache/ActivityCacheService.ts +++ b/packages/extension/src/background/services/activity/cache/ActivityCacheService.ts @@ -1,8 +1,4 @@ -import { - getAccountIdentifier, - stripAddressZeroPadding, - type IHttpService, -} from "@argent/x-shared" +import { stripAddressZeroPadding, type IHttpService } from "@argent/x-shared" import { type Activity, type ActivityResponse, @@ -42,8 +38,7 @@ export class ActivityCacheService implements IActivityCacheService { account: BaseWalletAccount, ): Promise { const { cache } = await this.activityCacheStore.get() - const key = getAccountIdentifier(account) - return cache[key] + return cache[account.id] } async setActivityCacheItem({ @@ -54,11 +49,10 @@ export class ActivityCacheService implements IActivityCacheService { account: BaseWalletAccount }): Promise { const { cache } = await this.activityCacheStore.get() - const key = getAccountIdentifier(account) await this.activityCacheStore.set({ cache: { ...cache, - [key]: activityCacheItem, + [account.id]: activityCacheItem, }, }) } diff --git a/packages/extension/src/background/services/activity/cache/mergeAndSortActivities.ts b/packages/extension/src/background/services/activity/cache/mergeAndSortActivities.ts index 8fa4d304d..db7d6ed4d 100644 --- a/packages/extension/src/background/services/activity/cache/mergeAndSortActivities.ts +++ b/packages/extension/src/background/services/activity/cache/mergeAndSortActivities.ts @@ -1,9 +1,9 @@ +import { isEqualAddress } from "@argent/x-shared" +import type { NativeActivity } from "@argent/x-shared/simulation" import { - NativeActivity, NativeActivityTypeNative, type AnyActivity, } from "@argent/x-shared/simulation" -import { isEqualAddress } from "@argent/x-shared" import { mergeArrayStableWith } from "../../../../shared/storage/__new/base" @@ -43,6 +43,9 @@ const options = { if (b.multisigDetails) { merged.multisigDetails = b.multisigDetails } + if (b.actions) { + merged.actions = b.actions + } return merged } diff --git a/packages/extension/src/background/services/activity/cache/worker/ActivityCacheWorker.test.ts b/packages/extension/src/background/services/activity/cache/worker/ActivityCacheWorker.test.ts index 94520b7cf..1de1f663b 100644 --- a/packages/extension/src/background/services/activity/cache/worker/ActivityCacheWorker.test.ts +++ b/packages/extension/src/background/services/activity/cache/worker/ActivityCacheWorker.test.ts @@ -1,7 +1,5 @@ -import { - AnyActivity, - NativeActivityTypeNative, -} from "@argent/x-shared/simulation" +import type { AnyActivity } from "@argent/x-shared/simulation" +import { NativeActivityTypeNative } from "@argent/x-shared/simulation" import Emittery from "emittery" import { describe, expect, test, vi, type Mocked } from "vitest" import { ActivityCacheWorker } from "./ActivityCacheWorker" @@ -15,7 +13,10 @@ import { type Transaction, } from "../../../../../shared/transactions" import { delay } from "../../../../../shared/utils/delay" -import type { BaseWalletAccount } from "../../../../../shared/wallet.model" +import { + SignerType, + type BaseWalletAccount, +} from "../../../../../shared/wallet.model" import { TransactionCreatedForAction, type Events as BackgroundActionServiceEvents, @@ -25,15 +26,27 @@ import type { Events as ActivityServiceEvents, IActivityService, } from "../../IActivityService" -import { MultisigEmitterEvents } from "../../../../../shared/multisig/emitter" -import { MultisigPendingTransaction } from "../../../../../shared/multisig/pendingTransactionsStore" +import type { MultisigEmitterEvents } from "../../../../../shared/multisig/emitter" +import type { MultisigPendingTransaction } from "../../../../../shared/multisig/pendingTransactionsStore" import { ArrayStorage } from "../../../../../shared/storage" import type { IAddressService } from "../../../../../shared/address/IAddressService" +import { getAccountIdentifier } from "../../../../../shared/utils/accountIdentifier" +import type { IKnownDappService } from "../../../../../shared/knownDapps/IKnownDappService" const networkId = "sepolia-alpha" +const address = + "0x05f1f0a38429dcab9ffd8a786c0d827e84c1cbd8f60243e6d25d066a13af4a25" + +const mockSigner = { + type: SignerType.LOCAL_SECRET, + derivationPath: "m/44'/60'/0'/0/0", +} + +const id = getAccountIdentifier(address, networkId, mockSigner) const account: BaseWalletAccount = { - address: "0x05f1f0a38429dcab9ffd8a786c0d827e84c1cbd8f60243e6d25d066a13af4a25", + id, + address, networkId, } @@ -89,6 +102,7 @@ describe("ActivityCacheWorker", () => { }) const addressService = {} as unknown as IAddressService + const knownDappService = {} as unknown as IKnownDappService const activityCacheWorker = new ActivityCacheWorker( activityService, @@ -99,6 +113,7 @@ describe("ActivityCacheWorker", () => { multisigEmitter, multisigPendingTransactionsStore, addressService, + knownDappService, ) return { activityService, @@ -118,12 +133,7 @@ describe("ActivityCacheWorker", () => { describe("When a Transaction is created for an Action", () => { test("Creates a new NativeActivity", async () => { const { - activityService, - activityServiceEmitter, activityCacheService, - transactionsRepo, - actionQueue, - actionService, actionServiceEmitter, activityCacheWorker, } = makeService() @@ -162,16 +172,8 @@ describe("ActivityCacheWorker", () => { describe("When a Transaction is updated", () => { test("Updates existing NativeActivity", async () => { - const { - activityService, - activityServiceEmitter, - activityCacheService, - transactionsRepo, - actionQueue, - actionService, - actionServiceEmitter, - activityCacheWorker, - } = makeService() + const { activityCacheService, transactionsRepo, activityCacheWorker } = + makeService() const onTransactionRepoChangeSpy = vi.spyOn( activityCacheWorker, diff --git a/packages/extension/src/background/services/activity/cache/worker/ActivityCacheWorker.ts b/packages/extension/src/background/services/activity/cache/worker/ActivityCacheWorker.ts index 3d67b88d2..bfe318411 100644 --- a/packages/extension/src/background/services/activity/cache/worker/ActivityCacheWorker.ts +++ b/packages/extension/src/background/services/activity/cache/worker/ActivityCacheWorker.ts @@ -1,7 +1,6 @@ import { isEqualAddress } from "@argent/x-shared" +import type { AnyActivity, NativeActivity } from "@argent/x-shared/simulation" import { - AnyActivity, - NativeActivity, NativeActivityTypeNative, createNativeActivity, } from "@argent/x-shared/simulation" @@ -33,13 +32,15 @@ import { DAPP_TRANSACTION_TITLE, getNativeActivityStatusForTransaction, } from "../../../../../shared/transactions/utils" -import { BaseWalletAccount } from "../../../../../shared/wallet.model" +import type { BaseWalletAccount } from "../../../../../shared/wallet.model" import { TransactionCreatedForAction, type IBackgroundActionService, } from "../../../action/IBackgroundActionService" import { Activities, type IActivityService } from "../../IActivityService" import { buildBasicActivitySummary } from "../../../../../shared/activity/utils/transform/activity/buildActivitySummary" +import type { IKnownDappService } from "../../../../../shared/knownDapps/IKnownDappService" +import { knownDappToTargetDappSchema } from "../../schema" export class ActivityCacheWorker { constructor( @@ -51,6 +52,7 @@ export class ActivityCacheWorker { private readonly multisigEmitter: Emittery, private readonly multisigPendingTransactionsStore: ArrayStorage, private readonly addressService: IAddressService, + private readonly knownDappsService: IKnownDappService, ) { /** (...args) rather than bind() pattern so we can use spyOn() in tests */ @@ -84,6 +86,18 @@ export class ActivityCacheWorker { await this.activityCacheService.upsertActivities({ account, activities }) } + async getDappInfoForOrigin(origin: string) { + const knownDapp = await this.knownDappsService.getDappByHost(origin) + + if (!knownDapp) { + return null + } + + const transformedDapp = knownDappToTargetDappSchema.parse(knownDapp) + + return transformedDapp + } + async transactionCreatedForAction({ actionHash, transactionHash, @@ -159,10 +173,19 @@ export class ActivityCacheWorker { ) nativeActivity.transferSummary = activitySummary } - const { address, networkId } = transaction.account + + if (action?.meta?.origin) { + const dapp = await this.getDappInfoForOrigin(action.meta.origin) + if (dapp) { + nativeActivity.dapp = dapp + } + } + + const { address, networkId, id } = transaction.account const account: BaseWalletAccount = { address, networkId, + id, } await this.activityCacheService.upsertActivities({ account, @@ -210,6 +233,7 @@ export class ActivityCacheWorker { const subtitle = await getTransactionSubtitle({ transactionTransformed, networkId: transaction.account.networkId, + accountId: transaction.account.id, getAddressName, }) if (transactionTransformed.displayName) { @@ -241,10 +265,11 @@ export class ActivityCacheWorker { ) nativeActivity.transferSummary = activitySummary } - const { address, networkId } = transaction.account + const { address, networkId, id } = transaction.account const account: BaseWalletAccount = { address, networkId, + id, } await this.activityCacheService.upsertActivities({ account, @@ -278,10 +303,11 @@ export class ActivityCacheWorker { status: newStatus, lastModified: Date.now(), } - const { address, networkId } = changedStatusTransaction.account + const { address, networkId, id } = changedStatusTransaction.account const account: BaseWalletAccount = { address, networkId, + id, } await this.activityCacheService.upsertActivities({ account, diff --git a/packages/extension/src/background/services/activity/cache/worker/index.ts b/packages/extension/src/background/services/activity/cache/worker/index.ts index ee72f75f2..eddb84dba 100644 --- a/packages/extension/src/background/services/activity/cache/worker/index.ts +++ b/packages/extension/src/background/services/activity/cache/worker/index.ts @@ -4,20 +4,19 @@ import { transactionsRepo } from "../../../../../shared/transactions/store" import { actionQueue } from "../../../../../shared/actionQueue" import { backgroundActionService } from "../../../action" import { activityCacheService } from ".." -import { isActivityV2FeatureEnabled } from "../../../../../shared/activity" import { multisigEmitter } from "../../../../../shared/multisig/emitter" import { multisigPendingTransactionsStore } from "../../../../../shared/multisig/pendingTransactionsStore" import { addressService } from "../../../../../shared/address" +import { knownDappsService } from "../../../../../shared/knownDapps/index" -export const activityCacheWorker = - isActivityV2FeatureEnabled && - new ActivityCacheWorker( - activityService, - activityCacheService, - transactionsRepo, - actionQueue, - backgroundActionService, - multisigEmitter, - multisigPendingTransactionsStore, - addressService, - ) +export const activityCacheWorker = new ActivityCacheWorker( + activityService, + activityCacheService, + transactionsRepo, + actionQueue, + backgroundActionService, + multisigEmitter, + multisigPendingTransactionsStore, + addressService, + knownDappsService, +) diff --git a/packages/extension/src/background/services/activity/index.ts b/packages/extension/src/background/services/activity/index.ts index 3768eb93c..b730424e7 100644 --- a/packages/extension/src/background/services/activity/index.ts +++ b/packages/extension/src/background/services/activity/index.ts @@ -35,6 +35,7 @@ export const activityService = new ActivityService( export const activityWorker = new ActivityWorker( activityService, + accountService, notificationService, activityCacheService, transactionTrackerWorker, diff --git a/packages/extension/src/background/services/activity/schema.ts b/packages/extension/src/background/services/activity/schema.ts new file mode 100644 index 000000000..ccd0a5867 --- /dev/null +++ b/packages/extension/src/background/services/activity/schema.ts @@ -0,0 +1,14 @@ +import { knownDappSchema } from "@argent/x-shared" + +// Create a transformed schema +export const knownDappToTargetDappSchema = knownDappSchema + .pick({ + name: true, + argentVerified: true, + }) + .extend({ + description: knownDappSchema.shape.description.default(""), + logoUrl: knownDappSchema.shape.logoUrl.default(""), + iconUrl: knownDappSchema.shape.logoUrl.default(""), + links: knownDappSchema.shape.links.default([]), + }) diff --git a/packages/extension/src/background/services/activity/worker/ActivityWorker.test.ts b/packages/extension/src/background/services/activity/worker/ActivityWorker.test.ts index 202b9a23c..12f4df88d 100644 --- a/packages/extension/src/background/services/activity/worker/ActivityWorker.test.ts +++ b/packages/extension/src/background/services/activity/worker/ActivityWorker.test.ts @@ -1,22 +1,29 @@ import "fake-indexeddb/auto" -import { afterEach, describe, Mocked, vi } from "vitest" +import type { Mocked } from "vitest" +import { afterEach, describe, vi } from "vitest" -import { emitterMock } from "../../../wallet/test.utils" import { ActivityWorker } from "./ActivityWorker" -import { IActivityCacheService } from "../../../../shared/activity/cache/IActivityCacheService" -import { IActivityService } from "../IActivityService" -import { INotificationService } from "../../../../shared/notifications/INotificationService" -import { - TransactionStatusChanged, - TransactionTrackerWorker, -} from "../../transactionTracker/worker/TransactionTrackerWorker" +import type { IActivityCacheService } from "../../../../shared/activity/cache/IActivityCacheService" +import type { IActivityService } from "../IActivityService" +import type { INotificationService } from "../../../../shared/notifications/INotificationService" +import type { TransactionTrackerWorker } from "../../transactionTracker/worker/TransactionTrackerWorker" +import { TransactionStatusChanged } from "../../transactionTracker/worker/TransactionTrackerWorker" +import type { IAccountService } from "../../../../shared/account/service/accountService/IAccountService" +import { getMockWalletAccount } from "../../../../../test/walletAccount.mock" +import { getRandomAccountIdentifier } from "../../../../shared/utils/accountIdentifier" +import { emitterMock } from "../../../../shared/test.utils" +import { delay } from "../../../../shared/utils/delay" + +vi.mock("../../../../shared/utils/delay") describe("ActivityWorker", () => { let activityService: Mocked + let mockAccountService: Mocked let notificationService: Mocked let transactionTrackerWorker: TransactionTrackerWorker let activityCacheService: IActivityCacheService let activityWorker: ActivityWorker + const mockActivity = { details: { srcAsset: { @@ -77,6 +84,7 @@ describe("ActivityWorker", () => { { address: "0x01979190a7a4b6e7a497c6086f7183629a41828d9cc1f47e4f4bb6848461974f", + id: "0x01979190a7a4b6e7a497c6086f7183629a41828d9cc1f47e4f4bb6848461974f::sepolia-alpha::local_secret::0", network: "starknet", type: "wallet", }, @@ -145,6 +153,18 @@ describe("ActivityWorker", () => { "0x01979190a7a4b6e7a497c6086f7183629a41828d9cc1f47e4f4bb6848461974f", } + const mockAddress = + "0x01979190a7a4b6e7a497c6086f7183629a41828d9cc1f47e4f4bb6848461974f" + const mockNetworkId = "sepolia-alpha" + + const mockId = getRandomAccountIdentifier(mockAddress, mockNetworkId) + + const mockAccount = getMockWalletAccount({ + address: mockAddress, + networkId: mockNetworkId, + id: mockId, + }) + beforeEach(() => { activityService = { updateAccountActivities: vi.fn(), @@ -159,9 +179,13 @@ describe("ActivityWorker", () => { makeId: vi.fn(), hasShown: vi.fn(), } as unknown as Mocked + mockAccountService = { + get: vi.fn().mockResolvedValue([mockAccount]), + } as unknown as Mocked activityWorker = new ActivityWorker( activityService, + mockAccountService, notificationService, activityCacheService, transactionTrackerWorker, @@ -226,6 +250,7 @@ describe("ActivityWorker", () => { account: { address: "0x01979190a7a4b6e7a497c6086f7183629a41828d9cc1f47e4f4bb6848461974f", + id: "0x01979190a7a4b6e7a497c6086f7183629a41828d9cc1f47e4f4bb6848461974f::sepolia-alpha::local_secret::0", networkId: "sepolia-alpha", }, }, @@ -242,6 +267,7 @@ describe("ActivityWorker", () => { account: { address: "0x01979190a7a4b6e7a497c6086f7183629a41828d9cc1f47e4f4bb6848461974f", + id: "0x01979190a7a4b6e7a497c6086f7183629a41828d9cc1f47e4f4bb6848461974f::sepolia-alpha::local_secret::0", networkId: "sepolia-alpha", }, }, @@ -251,5 +277,75 @@ describe("ActivityWorker", () => { }, ) }) + + it("should poll if there are no activities yet", async () => { + activityService.updateSelectedAccountActivities + .mockReturnValueOnce([] as any) + .mockReturnValueOnce([] as any) + .mockReturnValueOnce([ + mockActivity, + { ...mockActivity, transaction: { hash: "0x1" } }, + ] as any) + + await activityWorker.onTransactionStatusChanged({ + transactions: [ + "0x006e58fdc2a6b6aa6b4d92348aa189afe206921be09dd51e776dca1e762d23da", + "0x1", + ], + }) + + expect(delay).toHaveBeenCalledTimes(2) + expect(notificationService.showWithDeepLink).toHaveBeenNthCalledWith( + 1, + { + id: undefined, + route: + "/account/activity/0x006e58fdc2a6b6aa6b4d92348aa189afe206921be09dd51e776dca1e762d23da?returnTo=%2Faccount%2Factivity", + account: { + address: + "0x01979190a7a4b6e7a497c6086f7183629a41828d9cc1f47e4f4bb6848461974f", + id: "0x01979190a7a4b6e7a497c6086f7183629a41828d9cc1f47e4f4bb6848461974f::sepolia-alpha::local_secret::0", + networkId: "sepolia-alpha", + }, + }, + { + title: "Swap", + status: "success", + }, + ) + expect(notificationService.showWithDeepLink).toHaveBeenNthCalledWith( + 2, + { + id: undefined, + route: "/account/activity/0x1?returnTo=%2Faccount%2Factivity", + account: { + address: + "0x01979190a7a4b6e7a497c6086f7183629a41828d9cc1f47e4f4bb6848461974f", + id: "0x01979190a7a4b6e7a497c6086f7183629a41828d9cc1f47e4f4bb6848461974f::sepolia-alpha::local_secret::0", + networkId: "sepolia-alpha", + }, + }, + { + title: "Swap", + status: "success", + }, + ) + }) + + it("should stop polling, if there are no activities after all delays", async () => { + activityService.updateSelectedAccountActivities.mockReturnValueOnce( + [] as any, + ) + + await activityWorker.onTransactionStatusChanged({ + transactions: [ + "0x006e58fdc2a6b6aa6b4d92348aa189afe206921be09dd51e776dca1e762d23da", + "0x1", + ], + }) + + expect(delay).toHaveBeenCalledTimes(5) + expect(notificationService.showWithDeepLink).not.toHaveBeenCalled() + }) }) }) diff --git a/packages/extension/src/background/services/activity/worker/ActivityWorker.ts b/packages/extension/src/background/services/activity/worker/ActivityWorker.ts index 711397bfe..619d46ceb 100644 --- a/packages/extension/src/background/services/activity/worker/ActivityWorker.ts +++ b/packages/extension/src/background/services/activity/worker/ActivityWorker.ts @@ -11,20 +11,28 @@ import type { } from "../../../../shared/notifications/INotificationService" import { routes } from "../../../../shared/ui/routes" import { starknetNetworkToNetworkId } from "../../../../shared/utils/starknetNetwork" + import type { BaseWalletAccount } from "../../../../shared/wallet.model" import type { IActivityService } from "../IActivityService" +import type { IAccountService } from "../../../../shared/account/service/accountService/IAccountService" +import type { TransactionTrackerWorker } from "../../transactionTracker/worker/TransactionTrackerWorker" +import { TransactionStatusChanged } from "../../transactionTracker/worker/TransactionTrackerWorker" import { - TransactionStatusChanged, - TransactionTrackerWorker, -} from "../../transactionTracker/worker/TransactionTrackerWorker" -import { Activity } from "@argent/x-shared/simulation" -import { IActivityCacheService } from "../../../../shared/activity/cache/IActivityCacheService" -import { argentDb } from "../../../../shared/idb/db" + isRejectOnChainActivity, + type Activity, +} from "@argent/x-shared/simulation" +import type { IActivityCacheService } from "../../../../shared/activity/cache/IActivityCacheService" +import { argentDb } from "../../../../shared/idb/argentDb" import { equalToken } from "../../../../shared/token/__new/utils" +import { delay } from "../../../../shared/utils/delay" + +// We poll activities instantly after a transaction status change, then we try 2*500ms and 3*1s intervals +const DELAYS = [...Array(2).fill(500), ...Array(3).fill(1000)] export class ActivityWorker { constructor( private readonly activityService: IActivityService, + private readonly accountService: IAccountService, private readonly notificationService: INotificationService, private readonly activityCacheService: IActivityCacheService, private readonly transactionTrackerWorker: TransactionTrackerWorker, @@ -39,6 +47,10 @@ export class ActivityWorker { activity: Activity, account: BaseWalletAccount, ) { + if (isRejectOnChainActivity(activity)) { + return "On-chain reject" + } + const title = activity.title || "" if (activity.details.type !== "payment") { @@ -76,15 +88,12 @@ export class ActivityWorker { } private async sendActivityNotification(activity: Activity) { - if (!activity.networkDetails) { + const account = await this.makeAccountFromActivity(activity) + + if (!account) { return } - const account: BaseWalletAccount = { - address: activity.wallet, - networkId: starknetNetworkToNetworkId( - activity.networkDetails.ethereumNetwork, - ), - } + const hash = activity.transaction.hash const id = this.notificationService.makeId({ hash, account }) if (this.notificationService.hasShown(id)) { @@ -128,21 +137,71 @@ export class ActivityWorker { ) } - async onTransactionStatusChanged({ - transactions, - }: { - transactions: string[] - }) { + private async getActivitiesForTransactions(transactions: string[]) { const activities = await this.activityService.updateSelectedAccountActivities() - const newActivities = ensureArray(activities).filter((activity) => + return ensureArray(activities).filter((activity) => transactions.some((transaction) => isEqualAddress(activity.transaction.hash, transaction), ), ) + } + + private async pollForActivities(transactions: string[]) { + for (const delayMs of DELAYS) { + await delay(delayMs) + const newActivities = + await this.getActivitiesForTransactions(transactions) + if (newActivities.length > 0) { + return newActivities + } + } + return [] + } + + async onTransactionStatusChanged({ + transactions, + }: { + transactions: string[] + }) { + let newActivities = await this.getActivitiesForTransactions(transactions) + + if (newActivities.length === 0) { + newActivities = await this.pollForActivities(transactions) + } await Promise.all( newActivities.map((activity) => this.sendActivityNotification(activity)), ) } + + private async makeAccountFromActivity(activity: Activity) { + if (!activity.networkDetails) { + return + } + + const network = starknetNetworkToNetworkId( + activity.networkDetails.ethereumNetwork, + ) + + const accounts = await this.accountService.get() + + // TODO: Find a better way to get the account + const maybeAccount = accounts.find( + (account) => + isEqualAddress(account.address, activity.wallet) && + account.networkId === network, + ) + + if (!maybeAccount) { + console.error("Account not found") + return + } + + return { + id: maybeAccount.id, + address: maybeAccount.address, + networkId: maybeAccount.networkId, + } + } } diff --git a/packages/extension/src/background/services/analytics/AnalyticsWoker.ts b/packages/extension/src/background/services/analytics/AnalyticsWoker.ts index 638bff916..2a1728d29 100644 --- a/packages/extension/src/background/services/analytics/AnalyticsWoker.ts +++ b/packages/extension/src/background/services/analytics/AnalyticsWoker.ts @@ -1,5 +1,5 @@ -import { AnalyticsService } from "../../../shared/analytics/AnalyticsService" -import { IBackgroundUIService } from "../ui/IBackgroundUIService" +import type { AnalyticsService } from "../../../shared/analytics/AnalyticsService" +import type { IBackgroundUIService } from "../ui/IBackgroundUIService" import { onOpen } from "../worker/schedule/decorators" import { pipe } from "../worker/schedule/pipe" diff --git a/packages/extension/src/background/services/argentAccount/BackgroundArgentAccountService.ts b/packages/extension/src/background/services/argentAccount/BackgroundArgentAccountService.ts index 40fc4e56b..0c3f89088 100644 --- a/packages/extension/src/background/services/argentAccount/BackgroundArgentAccountService.ts +++ b/packages/extension/src/background/services/argentAccount/BackgroundArgentAccountService.ts @@ -1,9 +1,9 @@ -import { +import type { IHttpService, - BaseError, AddSmartAccountRequest, AddSmartAccountResponse, } from "@argent/x-shared" +import { BaseError } from "@argent/x-shared" import { stringToBytes } from "@scure/base" import { keccak, pedersen } from "micro-starknet" import { num, stark } from "starknet" @@ -14,12 +14,12 @@ import { } from "../../../shared/account/selectors" import { accountService } from "../../../shared/account/service" import { ARGENT_ACCOUNT_PREFERENCES_URL } from "../../../shared/api/constants" -import { IBackgroundArgentAccountService } from "./IBackgroundArgentAccountService" -import { +import type { IBackgroundArgentAccountService } from "./IBackgroundArgentAccountService" +import type { Flow, PreferencesPayload, - preferencesEndpointPayload, } from "../../../shared/argentAccount/schema" +import { preferencesEndpointPayload } from "../../../shared/argentAccount/schema" import { addBackendAccount, emailVerificationStatusErrorSchema, @@ -32,8 +32,8 @@ import { import { SMART_ACCOUNT_NETWORK_ID } from "../../../shared/smartAccount/constants" import { generateJwt } from "../../../shared/smartAccount/jwt" import { validateEmailForAccounts } from "../../../shared/smartAccount/validation/validateAccount" -import { Wallet } from "../../wallet" -import { WalletAccountSharedService } from "../../../shared/account/service/accountSharedService/WalletAccountSharedService" +import type { Wallet } from "../../wallet" +import type { WalletAccountSharedService } from "../../../shared/account/service/accountSharedService/WalletAccountSharedService" export default class BackgroundArgentAccountService implements IBackgroundArgentAccountService diff --git a/packages/extension/src/background/services/dev/DevWorker.ts b/packages/extension/src/background/services/dev/DevWorker.ts new file mode 100644 index 000000000..cb98c05c7 --- /dev/null +++ b/packages/extension/src/background/services/dev/DevWorker.ts @@ -0,0 +1,40 @@ +import type { IDevStorage } from "../../../shared/dev/types" +import type { IOnboardingService } from "../../../shared/onboarding/IOnboardingService" +import type { KeyValueStorage } from "../../../shared/storage/keyvalue" +import { delay } from "../../../shared/utils/delay" + +export class DevWorker { + constructor( + private devStore: KeyValueStorage, + private onboardingService: IOnboardingService, + ) { + devStore.subscribe( + "openInExtendedView", + this.openInExtendedViewChange.bind(this), + ) + void (async () => { + await delay(0) // Allow onboarding service to initialize so we can override if it calls iconClickOpensPopup() + const openInExtendedView = await this.devStore.get("openInExtendedView") + if (openInExtendedView) { + console.log( + "App will open in extended view - use `pnpm dev:ui` or reset storage to change", + ) + this.onboardingService.iconClickOpensOnboarding() + } + })() + } + + async openInExtendedViewChange(openInExtendedView: boolean) { + if (openInExtendedView) { + this.onboardingService.iconClickOpensOnboarding() + } else { + const onboardingComplete = + await this.onboardingService.getOnboardingComplete() + if (onboardingComplete) { + this.onboardingService.iconClickOpensPopup() + } else { + this.onboardingService.iconClickOpensOnboarding() + } + } + } +} diff --git a/packages/extension/src/background/services/dev/index.ts b/packages/extension/src/background/services/dev/index.ts new file mode 100644 index 000000000..18178decf --- /dev/null +++ b/packages/extension/src/background/services/dev/index.ts @@ -0,0 +1,8 @@ +import { devStore } from "../../../shared/dev/store" +import { IS_DEV } from "../../../shared/utils/dev" +import { onboardingService } from "../onboarding" +import { DevWorker } from "./DevWorker" + +export const devWorker = IS_DEV + ? new DevWorker(devStore, onboardingService) + : undefined diff --git a/packages/extension/src/background/services/investments/IBackgroundInvestmentService.ts b/packages/extension/src/background/services/investments/IBackgroundInvestmentService.ts new file mode 100644 index 000000000..91d5d39cd --- /dev/null +++ b/packages/extension/src/background/services/investments/IBackgroundInvestmentService.ts @@ -0,0 +1,14 @@ +import type { ApiDefiPositions } from "@argent/x-shared" +import type { IInvestmentService } from "../../../shared/investments/IInvestmentService" +import type { AccountInvestments } from "../../../shared/investments/types" + +export interface IBackgroundInvestmentService extends IInvestmentService { + get investmentUrl(): string + fetchInvestmentsForAccount(accountAddress: string): Promise + + updateInvestmentsForAccounts( + accountInvestements: AccountInvestments[], + ): Promise + updateStakingEnabled(enabled: boolean): Promise + updateStakingApyPercentage(apyPercentage: string): Promise +} diff --git a/packages/extension/src/background/services/investments/InvestmentService.test.ts b/packages/extension/src/background/services/investments/InvestmentService.test.ts new file mode 100644 index 000000000..04c17fc17 --- /dev/null +++ b/packages/extension/src/background/services/investments/InvestmentService.test.ts @@ -0,0 +1,128 @@ +import type { Mocked } from "vitest" +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" +import { InvestmentService } from "./InvestmentService" +import type { + IHttpService, + Investment, + InvestmentsResponse, +} from "@argent/x-shared" +import { addressSchema } from "@argent/x-shared" +import { ArgentDatabase } from "../../../shared/idb/db" +import type { AccountInvestments } from "../../../shared/investments/types" +import { stark } from "starknet" +import type { IStakingStore } from "../../../shared/staking/storage" +import type { KeyValueStorage } from "../../../shared/storage" + +describe("InvestmentService", () => { + let service: InvestmentService + let mockHttpService: Mocked + let db: ArgentDatabase + + beforeEach(() => { + mockHttpService = vi.mocked({ + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + delete: vi.fn(), + }) + + db = new ArgentDatabase({ skipAddressNormalizer: true }) + + const stakingStore = { + set: vi.fn(), + } + + service = new InvestmentService( + "https://example.com/api", + mockHttpService, + db, + stakingStore as unknown as KeyValueStorage, + ) + }) + + afterEach(() => { + vi.clearAllMocks() + void db.delete() + }) + + describe("getAllInvestments", () => { + it("should return investments from the API response", async () => { + const mockInvestments = [ + { id: "1", category: "strkDelegatedStaking", name: "Investment 1" }, + { id: "2", category: "other", name: "Investment 2" }, + ] as Investment[] + const mockResponse: InvestmentsResponse = { investments: mockInvestments } + mockHttpService.get.mockResolvedValueOnce(mockResponse) + + const investments = await service.getAllInvestments() + + expect(investments).toEqual(mockInvestments) + expect(mockHttpService.get).toHaveBeenCalledWith( + "https://example.com/api/investments?chain=starknet¤cy=USD&application=argentx", + ) + }) + + it("should return an empty array if the API response is empty", async () => { + const mockResponse: InvestmentsResponse = { investments: [] } + mockHttpService.get.mockResolvedValueOnce(mockResponse) + + const investments = await service.getAllInvestments() + + expect(investments).toEqual([]) + }) + }) + + describe("updateInvestmentsForAccounts", () => { + it("should update investments in the database for accounts with new or changed investments", async () => { + const mockUpdates = [ + { + address: addressSchema.parse(stark.randomAddress()), + networkId: "starknet", + defiDecomposition: [ + { dappId: "1", products: [{ productId: "123" }] }, + ], + }, + { + address: addressSchema.parse(stark.randomAddress()), + networkId: "starknet", + defiDecomposition: [ + { dappId: "2", products: [{ productId: "456" }] }, + ], + }, + ] as AccountInvestments[] + + await service.updateInvestmentsForAccounts(mockUpdates) + + const updatedInvestments = await db.investments.toArray() + expect(updatedInvestments).toEqual(expect.arrayContaining(mockUpdates)) + }) + + it("should not update investments in the database if there are no changes", async () => { + const address = addressSchema.parse(stark.randomAddress()) + + const mockUpdates = [ + { + address, + networkId: "starknet", + defiDecomposition: [ + { dappId: "1", products: [{ productId: "123" }] }, + ], + }, + ] as AccountInvestments[] + const existingInvestment = { + address, + networkId: "starknet", + defiDecomposition: [{ dappId: "1", products: [{ productId: "123" }] }], + } as AccountInvestments + await db.investments.put(existingInvestment) + + await service.updateInvestmentsForAccounts(mockUpdates) + + const dbPutSpy = vi.spyOn(db.investments, "put") + + expect(dbPutSpy).not.toHaveBeenCalled() + const updatedInvestments = await db.investments.toArray() + expect(updatedInvestments).toEqual(expect.arrayContaining(mockUpdates)) + }) + }) +}) diff --git a/packages/extension/src/background/services/investments/InvestmentService.ts b/packages/extension/src/background/services/investments/InvestmentService.ts new file mode 100644 index 000000000..9494ef8da --- /dev/null +++ b/packages/extension/src/background/services/investments/InvestmentService.ts @@ -0,0 +1,114 @@ +import type { + IHttpService, + Investment, + InvestmentsResponse, + StrkDelegatedStakingInvestment, + ApiDefiPositions, +} from "@argent/x-shared" +import { + urlWithQuery, + safeParseAndWarn, + apiDefiPositionsSchema, + addressSchema, +} from "@argent/x-shared" +import type { IBackgroundInvestmentService } from "./IBackgroundInvestmentService" +import type { ArgentDatabase } from "../../../shared/idb/db" +import { isEqual } from "lodash-es" +import type { + AccountInvestmentsKey, + AccountInvestments, +} from "../../../shared/investments/types" +import { chunkedBulkPut } from "../../../shared/idb/utils/chunkedBulkPut" +import type { IStakingStore } from "../../../shared/staking/storage" +import type { KeyValueStorage } from "../../../shared/storage" + +export class InvestmentService implements IBackgroundInvestmentService { + constructor( + private readonly argentTokensDefiInvestmentsUrl: string = "", + private readonly httpService: IHttpService, + private readonly db: ArgentDatabase, + private readonly stakingStore: KeyValueStorage, + ) {} + + get investmentUrl() { + return this.argentTokensDefiInvestmentsUrl + } + + private get defaultQueryParams() { + return { + chain: "starknet", + currency: "USD", + application: "argentx", + } + } + + async getAllInvestments(): Promise { + const url = urlWithQuery( + [this.argentTokensDefiInvestmentsUrl, "/investments"], + this.defaultQueryParams, + ) + const response = await this.httpService.get(url) + return response?.investments ?? [] + } + + async getStrkDelegatedStakingInvestments(): Promise< + StrkDelegatedStakingInvestment[] + > { + const investments = await this.getAllInvestments() + return investments + .filter((investment) => investment.category === "strkDelegatedStaking") + .filter((investment) => investment.buyEnabled) // Need to do it in 2 steps to make TS happy + } + async fetchInvestmentsForAccount( + accountAddress: string, + ): Promise { + const url = urlWithQuery( + [ + this.argentTokensDefiInvestmentsUrl, + addressSchema.parse(accountAddress), + "investments", + ], + this.defaultQueryParams, + ) + + const response = await this.httpService.get(url) + + const validationResult = safeParseAndWarn(apiDefiPositionsSchema, response) + + if (!validationResult.success) { + console.error( + "Backend schema has changed. Make the changes to the models to prevent unknown/unhandled errors.", + ) + } + + return response + } + + async updateInvestmentsForAccounts( + updates: AccountInvestments[], + ): Promise { + const keys: AccountInvestmentsKey[] = updates.map( + ({ address, networkId }) => [address, networkId], + ) + const existingInvestments = await this.db.investments.bulkGet(keys) + const updatedInvestments = updates.filter((update, index) => { + const existing = existingInvestments[index] + return ( + !existing || + !isEqual(existing.defiDecomposition, update.defiDecomposition) + ) + }) + + if (updatedInvestments.length > 0) { + await chunkedBulkPut(this.db.investments, updatedInvestments) + } + } + + async updateStakingEnabled(enabled: boolean): Promise { + await this.stakingStore.set("enabled", enabled) + } + + async updateStakingApyPercentage(apyPercentage: string): Promise { + await this.stakingStore.set("apyPercentage", apyPercentage) + } +} diff --git a/packages/extension/src/background/services/investments/index.ts b/packages/extension/src/background/services/investments/index.ts new file mode 100644 index 000000000..733916bff --- /dev/null +++ b/packages/extension/src/background/services/investments/index.ts @@ -0,0 +1,12 @@ +import { ARGENT_TOKENS_DEFI_INVESTMENTS_URL } from "../../../shared/api/constants" +import { httpService } from "../../../shared/http/singleton" +import { argentDb } from "../../../shared/idb/argentDb" +import { InvestmentService } from "./InvestmentService" +import { stakingStore } from "../../../shared/staking/storage" + +export const investmentService = new InvestmentService( + ARGENT_TOKENS_DEFI_INVESTMENTS_URL, + httpService, + argentDb, + stakingStore, +) diff --git a/packages/extension/src/background/services/investments/worker/InvestmentWorker.ts b/packages/extension/src/background/services/investments/worker/InvestmentWorker.ts new file mode 100644 index 000000000..63870e13d --- /dev/null +++ b/packages/extension/src/background/services/investments/worker/InvestmentWorker.ts @@ -0,0 +1,186 @@ +import type { AllowArray } from "starknet" +import type { IAccountService } from "../../../../shared/account/service/accountService/IAccountService" +import { AccountAddedEvent } from "../../../../shared/account/service/accountService/IAccountService" +import { RefreshIntervalInSeconds } from "../../../../shared/config" +import type { IDebounceService } from "../../../../shared/debounce" +import type { IScheduleService } from "../../../../shared/schedule/IScheduleService" +import type { KeyValueStorage } from "../../../../shared/storage" +import type { + BaseWalletAccount, + NetworkOnlyPlaceholderAccount, +} from "../../../../shared/wallet.model" +import { isNetworkOnlyPlaceholderAccount } from "../../../../shared/wallet.model" +import type { WalletStorageProps } from "../../../../shared/wallet/walletStore" +import { Recovered } from "../../../wallet/recovery/IWalletRecoveryService" +import type { WalletRecoverySharedService } from "../../../wallet/recovery/WalletRecoverySharedService" +import type { IActivityService } from "../../activity/IActivityService" +import { Activities } from "../../activity/IActivityService" +import type { IBackgroundUIService } from "../../ui/IBackgroundUIService" +import { everyWhenOpen } from "../../worker/schedule/decorators" +import { pipe } from "../../worker/schedule/pipe" +import type { IBackgroundInvestmentService } from "../IBackgroundInvestmentService" +import { + addressSchema, + bigDecimal, + ensureArray, + prettifyCurrencyNumber, +} from "@argent/x-shared" +import { parseDefiDecomposition } from "../../../../shared/defiDecomposition/helpers/parseDefiDecomposition" +import { defaultNetwork } from "../../../../shared/network" +import type { ITokenService } from "../../../../shared/token/__new/service/ITokenService" +import type { ActivitiesPayload } from "../../../../shared/activity/types" + +export class InvestmentWorker { + constructor( + private readonly walletStore: KeyValueStorage, + private readonly investmentService: IBackgroundInvestmentService, + private readonly accountService: IAccountService, + private readonly tokensService: ITokenService, + private readonly activityService: IActivityService, + private readonly recoverySharedService: WalletRecoverySharedService, + private readonly backgroundUIService: IBackgroundUIService, + private readonly scheduleService: IScheduleService, + private readonly debounceService: IDebounceService, + ) { + // Listen for account changes + this.walletStore.subscribe( + "selected", + this.onSelectedAccountChange.bind(this), + ) + // Listen for recovery event + this.recoverySharedService.emitter.on( + Recovered, + this.onRecovered.bind(this), + ) + + // Listen for new account added event + this.accountService.emitter.on( + AccountAddedEvent, + this.runUpdatesForAccount.bind(this), + ) + + // Listen for activities + this.activityService.emitter.on(Activities, this.onActivity.bind(this)) + } + + runOnOpenAndUnlocked = pipe( + everyWhenOpen( + this.backgroundUIService, + this.scheduleService, + this.debounceService, + RefreshIntervalInSeconds.FAST, + "InvestmentsWorker.onOpenAndUnlocked", + ), // This will run the function when the wallet is opened and unlocked, debounced to 20s + )(async () => { + const selectedAccount = await this.getSelectedAccount() + if (!selectedAccount || isNetworkOnlyPlaceholderAccount(selectedAccount)) { + return + } + this.runUpdatesForAccount(selectedAccount) + }) + + async getSelectedAccount() { + const selectedAccount = await this.walletStore.get("selected") + return selectedAccount + } + + async onSelectedAccountChange( + account?: BaseWalletAccount | NetworkOnlyPlaceholderAccount | null, + ) { + if (!account || isNetworkOnlyPlaceholderAccount(account)) { + return + } + this.runUpdatesForAccount(account) + } + + async onRecovered(accounts: BaseWalletAccount[]) { + await this.fetchAndUpdateInvestmentsForAccount(accounts) + } + + runUpdatesForAccount(account: BaseWalletAccount) { + void this.fetchAndUpdateInvestmentsForAccount(account) + } + + // After detecting new successful activity, DeFi positions are fetched 5 times at 1-second intervals + async onActivity({ account }: ActivitiesPayload) { + await this.pollInvestments(account) + } + + async fetchAndUpdateInvestmentsForAccount( + accounts: AllowArray, + ) { + // Run only for default network + const defaultNetworkAccounts = ensureArray(accounts).filter( + (a) => a.networkId === defaultNetwork.id, + ) + + const tokens = await this.tokensService.getTokens( + (t) => t.networkId === defaultNetwork.id, + ) + + const investments = await Promise.all( + defaultNetworkAccounts.map(async (acc) => { + const investments = + await this.investmentService.fetchInvestmentsForAccount(acc.address) + const parsedInvestments = parseDefiDecomposition( + investments, + acc, + tokens, + ) + return { + address: addressSchema.parse(acc.address), + networkId: acc.networkId, + defiDecomposition: parsedInvestments, + } + }), + ) + + await this.investmentService.updateInvestmentsForAccounts(investments) + } + + async pollInvestments( + accounts: AllowArray, + attempts = 5, + interval = 1000, + ) { + for (let i = 0; i < attempts; i++) { + // Implement ETag caching when available + await this.fetchAndUpdateInvestmentsForAccount(accounts) + + if (i < attempts - 1) { + await new Promise((resolve) => setTimeout(resolve, interval)) + } + } + } + + runOnOpenAndUnlockedStakingEnabled = pipe( + everyWhenOpen( + this.backgroundUIService, + this.scheduleService, + this.debounceService, + RefreshIntervalInSeconds.MEDIUM, + "InvestmentsWorker.onOpenAndUnlockedStakingEnabled", + ), // This will run the function when the wallet is opened and unlocked, debounced to 1m + )(async () => { + const stakingInvestments = + await this.investmentService.getStrkDelegatedStakingInvestments() + const stakingEnabled = stakingInvestments.some( + (investment) => investment.buyEnabled || investment.sellEnabled, + ) + + const apyPercentage = + prettifyCurrencyNumber( + bigDecimal.formatUnits( + bigDecimal.mul( + bigDecimal.parseUnits( + stakingInvestments[0].metrics.totalApy ?? "0", + ), + bigDecimal.toBigDecimal(100, 0), + ), + ), + ) || "0" + + void this.investmentService.updateStakingEnabled(stakingEnabled) + void this.investmentService.updateStakingApyPercentage(apyPercentage) + }) +} diff --git a/packages/extension/src/background/services/investments/worker/index.ts b/packages/extension/src/background/services/investments/worker/index.ts new file mode 100644 index 000000000..1e6381286 --- /dev/null +++ b/packages/extension/src/background/services/investments/worker/index.ts @@ -0,0 +1,23 @@ +import { activityService } from "../../activity" +import { backgroundUIService } from "../../ui" +import { recoverySharedService } from "../../../walletSingleton" +import { debounceService } from "../../../../shared/debounce" +import { chromeScheduleService } from "../../../../shared/schedule" +import { old_walletStore } from "../../../../shared/wallet/walletStore" + +import { accountService } from "../../../../shared/account/service" +import { InvestmentWorker } from "./InvestmentWorker" +import { investmentService } from ".." +import { tokenService } from "../../../../shared/token/__new/service" + +export const investmentWorker = new InvestmentWorker( + old_walletStore, // TODO: remove old_walletStore. Make walletStore work properly + investmentService, + accountService, + tokenService, + activityService, + recoverySharedService, + backgroundUIService, + chromeScheduleService, + debounceService, +) diff --git a/packages/extension/src/background/services/knownDapps/worker/KnownDappsWorker.ts b/packages/extension/src/background/services/knownDapps/worker/KnownDappsWorker.ts index 673d750a0..9dc0c18d3 100644 --- a/packages/extension/src/background/services/knownDapps/worker/KnownDappsWorker.ts +++ b/packages/extension/src/background/services/knownDapps/worker/KnownDappsWorker.ts @@ -1,11 +1,12 @@ -import { IScheduleService } from "../../../../shared/schedule/IScheduleService" -import { KnownDappService } from "../../../../shared/knownDapps/KnownDappService" +import type { IScheduleService } from "../../../../shared/schedule/IScheduleService" +import type { KnownDappService } from "../../../../shared/knownDapps/KnownDappService" import { RefreshIntervalInSeconds } from "../../../../shared/config" import { pipe } from "../../worker/schedule/pipe" -import { IBackgroundUIService } from "../../ui/IBackgroundUIService" -import { IDebounceService } from "../../../../shared/debounce" +import type { IBackgroundUIService } from "../../ui/IBackgroundUIService" +import type { IDebounceService } from "../../../../shared/debounce" import { everyWhenOpen } from "../../worker/schedule/decorators" +// eslint-disable-next-line @typescript-eslint/no-unused-vars const id = "knownDappsUpdate" type Id = typeof id diff --git a/packages/extension/src/background/services/multisig/BackgroundMultisigService.ts b/packages/extension/src/background/services/multisig/BackgroundMultisigService.ts index fcf6635ae..952e77033 100644 --- a/packages/extension/src/background/services/multisig/BackgroundMultisigService.ts +++ b/packages/extension/src/background/services/multisig/BackgroundMultisigService.ts @@ -4,17 +4,19 @@ import { decodeBase58, decodeBase58Array, estimatedFeeToMaxResourceBounds, + isEqualAddress, removeOwnersCalldataSchema, replaceSignerCalldataSchema, transferCalldataSchema, } from "@argent/x-shared" -import { ArraySignatureType, CallData, TransactionType } from "starknet" +import type { ArraySignatureType } from "starknet" +import { CallData, TransactionType } from "starknet" import { tryToMintAllFeeTokens } from "../../../shared/devnet/mintFeeToken" import { AccountError } from "../../../shared/errors/account" import { MultisigError } from "../../../shared/errors/multisig" import { TransactionError } from "../../../shared/errors/transaction" import { MultisigAccount } from "../../../shared/multisig/account" -import { +import type { AddAccountPayload, AddOwnerMultisigPayload, MultisigSignerSignatures, @@ -28,29 +30,32 @@ import { getMultisigPendingTransaction, removeFromMultisigPendingTransactions, } from "../../../shared/multisig/pendingTransactionsStore" -import { AddAccountResponse } from "../../../shared/multisig/service/messaging/IMultisigService" +import type { AddAccountResponse } from "../../../shared/multisig/service/messaging/IMultisigService" +import type { PendingMultisig } from "../../../shared/multisig/types" import { MultisigEntryPointType, MultisigTransactionType, - PendingMultisig, } from "../../../shared/multisig/types" import { getMultisigAccountFromBaseWallet, getMultisigAccounts, } from "../../../shared/multisig/utils/baseMultisig" import { ETH_TOKEN_ADDRESS } from "../../../shared/network/constants" -import { +import type { ExtendedFinalityStatus, - nameTransaction, TransactionRequest, } from "../../../shared/transactions" +import { nameTransaction } from "../../../shared/transactions" import { addTransaction } from "../../../shared/transactions/store" import { getEstimatedFees } from "../../../shared/transactionSimulation/fees/estimatedFeesRepository" -import { BaseWalletAccount, SignerType } from "../../../shared/wallet.model" +import type { + BaseWalletAccount, + SignerType, +} from "../../../shared/wallet.model" import { hexBigIntSort } from "../../utils/bigIntSort" -import { Wallet } from "../../wallet" -import { IBackgroundActionService } from "../action/IBackgroundActionService" -import { IBackgroundMultisigService } from "./IBackgroundMultisigService" +import type { Wallet } from "../../wallet" +import type { IBackgroundActionService } from "../action/IBackgroundActionService" +import type { IBackgroundMultisigService } from "./IBackgroundMultisigService" export default class BackgroundMultisigService implements IBackgroundMultisigService @@ -69,6 +74,8 @@ export default class BackgroundMultisigService publicKey, updatedAt, signerType, + index, + derivationPath, } = payload const account = await this.wallet.newAccount( @@ -81,6 +88,8 @@ export default class BackgroundMultisigService creator, publicKey, updatedAt, + index, + derivationPath, }, ) await tryToMintAllFeeTokens(account) @@ -128,7 +137,7 @@ export default class BackgroundMultisigService }, { title, - icon: "MultisigJoinIcon", + icon: "AddContactSecondaryIcon", }, ) } @@ -170,7 +179,7 @@ export default class BackgroundMultisigService }, { title, - icon: "MultisigRemoveIcon", + icon: "RemoveContactSecondaryIcon", }, ) } @@ -228,7 +237,6 @@ export default class BackgroundMultisigService ): Promise { const multisigStarknetAccount = await this.getMultisigStarknetAccount() const transactionToSign = await getMultisigPendingTransaction(requestId) - if (!transactionToSign) { throw new MultisigError({ code: "PENDING_MULTISIG_TRANSACTION_NOT_FOUND", @@ -286,7 +294,7 @@ export default class BackgroundMultisigService async deploy(account: BaseWalletAccount): Promise { let displayCalldata: string[] = [] - const walletAccount = await this.wallet.getAccount(account) + const walletAccount = await this.wallet.getArgentAccount(account.id) if (!walletAccount) { throw new AccountError({ code: "MULTISIG_NOT_FOUND" }) } @@ -318,7 +326,7 @@ export default class BackgroundMultisigService }, { title: "Activate multisig", - icon: "MultisigIcon", + icon: "MultisigSecondaryIcon", }, ) } @@ -405,7 +413,7 @@ export default class BackgroundMultisigService }, { title: "On-chain rejection", - icon: "CloseIcon", + icon: "CrossSecondaryIcon", }, ) } @@ -417,8 +425,9 @@ export default class BackgroundMultisigService throw new AccountError({ code: "NOT_SELECTED" }) } - const multisigStarknetAccount = - await this.wallet.getStarknetAccount(selectedAccount) + const multisigStarknetAccount = await this.wallet.getStarknetAccount( + selectedAccount.id, + ) if (!MultisigAccount.isMultisig(multisigStarknetAccount)) { throw new AccountError({ code: "NOT_MULTISIG" }) @@ -471,6 +480,19 @@ export default class BackgroundMultisigService const acc = await this.getMultisigStarknetAccount() + // this can happen if the multisig owner was changed with ledger after the transaction was created + if ( + !pendingMultisigTransaction.approvedSigners.find((signer) => + isEqualAddress(signer, multisig?.publicKey), + ) + ) { + throw new MultisigError({ + code: "INVALID_SIGNER", + message: + "The signature of the transaction is not valid anymore - please reject this transaction and try again", + }) + } + const transaction = await acc.execute( pendingMultisigTransaction.transaction.calls, { diff --git a/packages/extension/src/background/services/multisig/IBackgroundMultisigService.ts b/packages/extension/src/background/services/multisig/IBackgroundMultisigService.ts index 52a2d75f9..c4430d0a2 100644 --- a/packages/extension/src/background/services/multisig/IBackgroundMultisigService.ts +++ b/packages/extension/src/background/services/multisig/IBackgroundMultisigService.ts @@ -1,5 +1,5 @@ -import { ArraySignatureType } from "starknet" -import { IMultisigService } from "../../../shared/multisig/service/messaging/IMultisigService" +import type { ArraySignatureType } from "starknet" +import type { IMultisigService } from "../../../shared/multisig/service/messaging/IMultisigService" export interface IBackgroundMultisigService extends IMultisigService { waitForOffchainSignatures(requestId: string): Promise diff --git a/packages/extension/src/background/services/network/BackgroundNetworkService.test.ts b/packages/extension/src/background/services/network/BackgroundNetworkService.test.ts index 2ccbb9207..354b88973 100644 --- a/packages/extension/src/background/services/network/BackgroundNetworkService.test.ts +++ b/packages/extension/src/background/services/network/BackgroundNetworkService.test.ts @@ -1,13 +1,14 @@ -import { Mocked, describe, expect, test, vi } from "vitest" +import type { Mocked } from "vitest" +import { describe, expect, test, vi } from "vitest" -import { Network } from "../../../shared/network" +import type { Network } from "../../../shared/network" import { defaultReadonlyNetworks } from "../../../shared/network/defaults" import { networkSelector } from "../../../shared/network/selectors" import { networksEqual } from "../../../shared/network/store" import { InMemoryRepository } from "../../../shared/storage/__new/__test__/inmemoryImplementations" import BackgroundNetworkService from "./BackgroundNetworkService" -import { NetworkWithStatus } from "../../../shared/network/type" -import { IHttpService } from "@argent/x-shared" +import type { NetworkWithStatus } from "../../../shared/network/type" +import type { IHttpService } from "@argent/x-shared" import { ETH_TOKEN_ADDRESS } from "../../../shared/network/constants" describe("BackgroundNetworkService", () => { diff --git a/packages/extension/src/background/services/network/BackgroundNetworkService.ts b/packages/extension/src/background/services/network/BackgroundNetworkService.ts index 6b263e8fa..2d6faf49e 100644 --- a/packages/extension/src/background/services/network/BackgroundNetworkService.ts +++ b/packages/extension/src/background/services/network/BackgroundNetworkService.ts @@ -1,15 +1,16 @@ import { uniqWith } from "lodash-es" -import { IHttpService } from "@argent/x-shared" +import type { IHttpService } from "@argent/x-shared" import urlJoin from "url-join" import { ARGENT_NETWORK_STATUS } from "../../../shared/api/constants" import { argentApiNetworkForNetwork } from "../../../shared/api/headers" import { NetworkError } from "../../../shared/errors/network" -import { ColorStatus, Network } from "../../../shared/network" +import type { ColorStatus, Network } from "../../../shared/network" import { colorStatusSchema } from "../../../shared/network/schema" -import { INetworkWithStatusRepo } from "../../../shared/network/statusStore" -import { INetworkRepo, networksEqual } from "../../../shared/network/store" -import { IBackgroundNetworkService } from "./IBackgroundNetworkService" +import type { INetworkWithStatusRepo } from "../../../shared/network/statusStore" +import type { INetworkRepo } from "../../../shared/network/store" +import { networksEqual } from "../../../shared/network/store" +import type { IBackgroundNetworkService } from "./IBackgroundNetworkService" export default class BackgroundNetworkService implements IBackgroundNetworkService @@ -55,7 +56,7 @@ export default class BackgroundNetworkService status: colorStatusSchema.parse(response.state), id: network.id, } - } catch (error) { + } catch { return { id: network.id, status: "unknown" as ColorStatus, diff --git a/packages/extension/src/background/services/network/IBackgroundNetworkService.ts b/packages/extension/src/background/services/network/IBackgroundNetworkService.ts index 1c0fe385a..eda2973d7 100644 --- a/packages/extension/src/background/services/network/IBackgroundNetworkService.ts +++ b/packages/extension/src/background/services/network/IBackgroundNetworkService.ts @@ -1,4 +1,4 @@ -import { Network, ColorStatus } from "../../../shared/network" +import type { Network, ColorStatus } from "../../../shared/network" export interface IBackgroundNetworkService { updateStatuses(): Promise diff --git a/packages/extension/src/background/services/network/index.ts b/packages/extension/src/background/services/network/index.ts index ed0d00ad4..427d0f31b 100644 --- a/packages/extension/src/background/services/network/index.ts +++ b/packages/extension/src/background/services/network/index.ts @@ -1,9 +1,11 @@ import { debounceService } from "../../../shared/debounce" import { httpService } from "../../../shared/http/singleton" import { defaultNetworks } from "../../../shared/network" +import { networkService } from "../../../shared/network/service" import { networkStatusRepo } from "../../../shared/network/statusStore" import { networkRepo } from "../../../shared/network/store" import { chromeScheduleService } from "../../../shared/schedule" +import { old_walletStore } from "../../../shared/wallet/walletStore" import { backgroundUIService } from "../ui" import BackgroundNetworkService from "./BackgroundNetworkService" import { NetworkWorker } from "./worker/NetworkWorker" @@ -16,8 +18,10 @@ export const backgroundNetworkService = new BackgroundNetworkService( ) export const networkWorker = new NetworkWorker( + networkService, backgroundNetworkService, backgroundUIService, + old_walletStore, chromeScheduleService, debounceService, ) diff --git a/packages/extension/src/background/services/network/worker/NetworkWorker.ts b/packages/extension/src/background/services/network/worker/NetworkWorker.ts index ca8d538a2..d943c5d0d 100644 --- a/packages/extension/src/background/services/network/worker/NetworkWorker.ts +++ b/packages/extension/src/background/services/network/worker/NetworkWorker.ts @@ -1,19 +1,38 @@ -import { IScheduleService } from "../../../../shared/schedule/IScheduleService" -import { IBackgroundNetworkService } from "../IBackgroundNetworkService" +import type { IScheduleService } from "../../../../shared/schedule/IScheduleService" +import type { IBackgroundNetworkService } from "../IBackgroundNetworkService" import { RefreshIntervalInSeconds } from "../../../../shared/config" import { everyWhenOpen } from "../../worker/schedule/decorators" -import { IBackgroundUIService } from "../../ui/IBackgroundUIService" -import { IDebounceService } from "../../../../shared/debounce" +import type { IBackgroundUIService } from "../../ui/IBackgroundUIService" +import type { IDebounceService } from "../../../../shared/debounce" +import type { + SelectedWalletStoreAccount, + WalletStorageProps, +} from "../../../../shared/wallet/walletStore" +import type { KeyValueStorage } from "../../../../shared/storage" +import { + declareContracts, + getPreDeployedAccount, +} from "../../../devnet/declareAccounts" +import type { INetworkService } from "../../../../shared/network/service/INetworkService" +import { loadContracts } from "../../../wallet/loadContracts" +// eslint-disable-next-line @typescript-eslint/no-unused-vars const TASK_ID = "NetworkWorker.updateStatuses" export class NetworkWorker { constructor( + private readonly networkService: INetworkService, private readonly backgroundNetworkService: IBackgroundNetworkService, private readonly backgroundUIService: IBackgroundUIService, + private walletStore: KeyValueStorage, private readonly scheduleService: IScheduleService, private readonly debounceService: IDebounceService, - ) {} + ) { + this.walletStore.subscribe( + "selected", + this.onSelectedAccountChange.bind(this), + ) + } updateNetworkStatuses = everyWhenOpen( this.backgroundUIService, @@ -24,4 +43,26 @@ export class NetworkWorker { )(async (): Promise => { await this.backgroundNetworkService.updateStatuses() }) + + private async onSelectedAccountChange(val?: SelectedWalletStoreAccount) { + + if (!val) { + return + } + + if (val.networkId === "localhost") { + const network = await this.networkService.getById(val.networkId) + const deployerAccount = await getPreDeployedAccount(network) + if (deployerAccount) { + const accountClassHash = await declareContracts( + network, + deployerAccount, + loadContracts, + ) + + // Should we keep for dev? + console.log("Declared account class hash", accountClassHash) + } + } + } } diff --git a/packages/extension/src/background/services/nft/worker/NftsWorker.ts b/packages/extension/src/background/services/nft/worker/NftsWorker.ts index e7eb2199c..02e577da6 100644 --- a/packages/extension/src/background/services/nft/worker/NftsWorker.ts +++ b/packages/extension/src/background/services/nft/worker/NftsWorker.ts @@ -1,21 +1,21 @@ import { addressSchema, isArgentNetworkId } from "@argent/x-shared" import { uniq } from "lodash-es" -import { IBackgroundUIService } from "../../ui/IBackgroundUIService" -import { Wallet } from "../../../wallet" -import { WalletSessionService } from "../../../wallet/session/WalletSessionService" +import type { IBackgroundUIService } from "../../ui/IBackgroundUIService" +import type { Wallet } from "../../../wallet" +import type { WalletSessionService } from "../../../wallet/session/WalletSessionService" import { RefreshIntervalInSeconds } from "../../../../shared/config" -import { INFTService } from "../../../../shared/nft/INFTService" -import { IScheduleService } from "../../../../shared/schedule/IScheduleService" -import { WalletStorageProps } from "../../../../shared/wallet/walletStore" -import { ArrayStorage, KeyValueStorage } from "../../../../shared/storage" -import { Transaction } from "../../../../shared/transactions" -import { hasSuccessfulTransaction } from "../../../../shared/utils/transactionSucceeded" +import type { INFTService } from "../../../../shared/nft/INFTService" +import type { IScheduleService } from "../../../../shared/schedule/IScheduleService" +import type { WalletStorageProps } from "../../../../shared/wallet/walletStore" +import type { KeyValueStorage } from "../../../../shared/storage" import { everyWhenOpen } from "../../worker/schedule/decorators" import { pipe } from "../../worker/schedule/pipe" -import { IDebounceService } from "../../../../shared/debounce" +import type { IDebounceService } from "../../../../shared/debounce" import { Recovered } from "../../../wallet/recovery/IWalletRecoveryService" -import { WalletRecoverySharedService } from "../../../wallet/recovery/WalletRecoverySharedService" +import type { WalletRecoverySharedService } from "../../../wallet/recovery/WalletRecoverySharedService" +import type { IActivityService } from "../../activity/IActivityService" +import { NftActivity } from "../../activity/IActivityService" export class NftsWorker { constructor( @@ -23,7 +23,7 @@ export class NftsWorker { private readonly scheduleService: IScheduleService, private readonly walletSingleton: Wallet, private walletStore: KeyValueStorage, - private readonly transactionsStore: ArrayStorage, + private readonly activityService: IActivityService, public readonly sessionService: WalletSessionService, private readonly backgroundUIService: IBackgroundUIService, private readonly debounceService: IDebounceService, @@ -38,21 +38,11 @@ export class NftsWorker { this.updateNftsCallback.bind(this), ) - /** update when a transaction succeeds (could be nft-related) */ - this.transactionsStore.subscribe((_, changeSet) => { - if (!changeSet?.newValue) { - return - } - const hasSuccessTx = hasSuccessfulTransaction( - changeSet.newValue, - changeSet?.oldValue, - ) - if (hasSuccessTx) { - setTimeout( - () => void this.updateNftsCallback(), - RefreshIntervalInSeconds.FAST * 1000, - ) // Add a delay so the backend has time to index the nft - } + this.activityService.emitter.on(NftActivity, () => { + setTimeout( + () => void this.updateNftsCallback(), + RefreshIntervalInSeconds.FAST * 1000, + ) // Add a delay so the backend has time to index the nft }) } @@ -66,17 +56,15 @@ export class NftsWorker { } try { + const accountAddress = addressSchema.parse(account.address) + const nfts = await this.nftsService.getAssets( "starknet", account.networkId, - addressSchema.parse(account.address), + accountAddress, ) - await this.nftsService.upsert( - nfts, - addressSchema.parse(account.address), - account.networkId, - ) + await this.nftsService.upsert(nfts, accountAddress, account.networkId) const contractsAddresses = uniq( nfts.map((nft) => ({ @@ -89,7 +77,7 @@ export class NftsWorker { "starknet", account.networkId, contractsAddresses, - addressSchema.parse(account.address), + accountAddress, ) } catch (e) { console.error(e) @@ -101,7 +89,7 @@ export class NftsWorker { this.backgroundUIService, this.scheduleService, this.debounceService, - RefreshIntervalInSeconds.SLOW, + RefreshIntervalInSeconds.MEDIUM, "NftsWorker.updateNfts", ), )(async () => { diff --git a/packages/extension/src/background/services/nft/worker/index.ts b/packages/extension/src/background/services/nft/worker/index.ts index 39734e782..51dd3ab90 100644 --- a/packages/extension/src/background/services/nft/worker/index.ts +++ b/packages/extension/src/background/services/nft/worker/index.ts @@ -2,7 +2,6 @@ import { nftService } from "../../../../shared/nft" import { debounceService } from "../../../../shared/debounce" import { chromeScheduleService } from "../../../../shared/schedule" import { old_walletStore } from "../../../../shared/wallet/walletStore" -import { transactionsStore } from "../../../../shared/transactions/store" import { recoverySharedService, sessionService, @@ -10,13 +9,14 @@ import { } from "../../../walletSingleton" import { backgroundUIService } from "../../ui" import { NftsWorker } from "./NftsWorker" +import { activityService } from "../../activity" export const nftsWorker = new NftsWorker( nftService, chromeScheduleService, walletSingleton, old_walletStore, - transactionsStore, + activityService, sessionService, backgroundUIService, debounceService, diff --git a/packages/extension/src/background/services/notifications/NotificationService.ts b/packages/extension/src/background/services/notifications/NotificationService.ts index 2b10911e0..27acd8ad2 100644 --- a/packages/extension/src/background/services/notifications/NotificationService.ts +++ b/packages/extension/src/background/services/notifications/NotificationService.ts @@ -1,4 +1,4 @@ -import { getAccountIdentifier, normalizeAddress } from "@argent/x-shared" +import { normalizeAddress } from "@argent/x-shared" import type { WalletAccountSharedService } from "../../../shared/account/service/accountSharedService/WalletAccountSharedService" import type { INotificationService, @@ -10,9 +10,9 @@ import { type NotificationDeepLink, } from "../../../shared/notifications/schema" import type { DeepPick } from "../../../shared/types/deepPick" -import { BaseWalletAccount } from "../../../shared/wallet.model" +import type { BaseWalletAccount } from "../../../shared/wallet.model" import type { IBackgroundUIService } from "../ui/IBackgroundUIService" -import { MinimalIWalletSessionService } from "../ui/BackgroundUIService" +import type { MinimalIWalletSessionService } from "../ui/BackgroundUIService" export type MinimalBrowser = DeepPick< typeof chrome, @@ -118,7 +118,7 @@ export class NotificationService implements INotificationService { const notificationDeepLinkParsed = notificationDeepLinkSchema.parse(notificationDeepLink) await this.accountSharedService.selectAccount( - notificationDeepLinkParsed.account, + notificationDeepLinkParsed.account.id, ) await this.backgroundUIService.openUi(notificationDeepLinkParsed.route) } catch { @@ -127,8 +127,6 @@ export class NotificationService implements INotificationService { } makeId({ hash, account }: { hash: string; account: BaseWalletAccount }) { - return [normalizeAddress(hash), getAccountIdentifier(account)] - .filter(Boolean) - .join(":") + return [normalizeAddress(hash), account.id].filter(Boolean).join(":") } } diff --git a/packages/extension/src/background/services/onRamp/OnRampService.ts b/packages/extension/src/background/services/onRamp/OnRampService.ts index 7cecdc779..d3be075ed 100644 --- a/packages/extension/src/background/services/onRamp/OnRampService.ts +++ b/packages/extension/src/background/services/onRamp/OnRampService.ts @@ -1,11 +1,12 @@ -import { Address } from "@argent/x-shared" +import type { Address } from "@argent/x-shared" import { TOPPER_BASE_URL, TOPPER_KEY_ID, TOPPER_WIDGET_ID, } from "../../../shared/api/constants" -import { IOnRampService } from "../../../shared/onRamp/IOnRampService" -import { importPKCS8, JWTHeaderParameters, SignJWT } from "jose" +import type { IOnRampService } from "../../../shared/onRamp/IOnRampService" +import type { JWTHeaderParameters } from "jose" +import { importPKCS8, SignJWT } from "jose" export class OnRampService implements IOnRampService { async getTopperUrl(address: Address) { diff --git a/packages/extension/src/background/services/onboarding/worker/OnboardingWorker.test.ts b/packages/extension/src/background/services/onboarding/worker/OnboardingWorker.test.ts index cfd692324..8a2540efd 100644 --- a/packages/extension/src/background/services/onboarding/worker/OnboardingWorker.test.ts +++ b/packages/extension/src/background/services/onboarding/worker/OnboardingWorker.test.ts @@ -17,7 +17,12 @@ describe("OnboardingWorker", () => { } as unknown as KeyValueStorage const browser = { runtime: { - getManifest: vi.fn(() => ({ manifest_version: 3 }) as any), + getManifest: vi.fn( + () => + ({ + manifest_version: 3, + }) as any, + ), onInstalled: { addListener: vi.fn(), }, diff --git a/packages/extension/src/background/services/onboarding/worker/OnboardingWorker.ts b/packages/extension/src/background/services/onboarding/worker/OnboardingWorker.ts index e200b49a4..7b5806978 100644 --- a/packages/extension/src/background/services/onboarding/worker/OnboardingWorker.ts +++ b/packages/extension/src/background/services/onboarding/worker/OnboardingWorker.ts @@ -1,7 +1,5 @@ -import { - MinimalActionBrowser, - getBrowserAction, -} from "../../../../shared/browser" +import type { MinimalActionBrowser } from "../../../../shared/browser" +import { getBrowserAction } from "../../../../shared/browser" import type { KeyValueStorage } from "../../../../shared/storage" import type { StorageChange } from "../../../../shared/storage/types" import type { DeepPick } from "../../../../shared/types/deepPick" diff --git a/packages/extension/src/background/services/recovery/BackgroundRecoveryService.test.ts b/packages/extension/src/background/services/recovery/BackgroundRecoveryService.test.ts index 467ed5769..1fa947a0a 100644 --- a/packages/extension/src/background/services/recovery/BackgroundRecoveryService.test.ts +++ b/packages/extension/src/background/services/recovery/BackgroundRecoveryService.test.ts @@ -1,8 +1,8 @@ import { describe, expect, test, vi } from "vitest" import { BackgroundRecoveryService } from "./BackgroundRecoveryService" -import { IObjectStore } from "../../../shared/storage/__new/interface" -import { IRecoveryStorage } from "../../../shared/recovery/types" +import type { IObjectStore } from "../../../shared/storage/__new/interface" +import type { IRecoveryStorage } from "../../../shared/recovery/types" import type { Wallet } from "../../wallet" import type { TransactionTrackerWorker } from "../transactionTracker/worker/TransactionTrackerWorker" diff --git a/packages/extension/src/background/services/recovery/BackgroundRecoveryService.ts b/packages/extension/src/background/services/recovery/BackgroundRecoveryService.ts index 208f2234d..4e9f7d54a 100644 --- a/packages/extension/src/background/services/recovery/BackgroundRecoveryService.ts +++ b/packages/extension/src/background/services/recovery/BackgroundRecoveryService.ts @@ -3,7 +3,7 @@ import type { IRecoveryService } from "../../../shared/recovery/IRecoveryService import { recoveredAtKeyValueStore } from "../../../shared/recovery/storage" import type { IRecoveryStorage } from "../../../shared/recovery/types" import type { IObjectStore } from "../../../shared/storage/__new/interface" -import { TransactionTrackerWorker } from "../transactionTracker/worker/TransactionTrackerWorker" +import type { TransactionTrackerWorker } from "../transactionTracker/worker/TransactionTrackerWorker" import type { Wallet } from "../../wallet" import { sanitiseSelectedAccount } from "../../../shared/wallet/sanitiseSelectedAccount" diff --git a/packages/extension/src/background/services/riskAssessment/BackgroundRiskAssessmentService.ts b/packages/extension/src/background/services/riskAssessment/BackgroundRiskAssessmentService.ts index 0719e0543..98e49691e 100644 --- a/packages/extension/src/background/services/riskAssessment/BackgroundRiskAssessmentService.ts +++ b/packages/extension/src/background/services/riskAssessment/BackgroundRiskAssessmentService.ts @@ -1,9 +1,9 @@ -import { IHttpService } from "@argent/x-shared" -import { +import type { IHttpService } from "@argent/x-shared" +import type { DappContext, IRiskAssessmentService, } from "../../../shared/riskAssessment/IRiskAssessmentService" -import { RiskAssessment } from "../../../shared/riskAssessment/schema" +import type { RiskAssessment } from "../../../shared/riskAssessment/schema" import { ARGENT_TRANSACTION_REVIEW_API_BASE_URL } from "../../../shared/api/constants" import urlJoin from "url-join" import { argentApiNetworkForNetwork } from "../../../shared/api/headers" @@ -38,7 +38,7 @@ export default class BackgroundRiskAssessmentService riskAssessmentEndpoint, ) return result - } catch (e) { + } catch { throw new RiskAssessmentError({ code: "ERROR_FETCHING" }) } } diff --git a/packages/extension/src/background/services/schedule/worker/ScheduleWorker.ts b/packages/extension/src/background/services/schedule/worker/ScheduleWorker.ts index ebea8d5f9..ef4de71d3 100644 --- a/packages/extension/src/background/services/schedule/worker/ScheduleWorker.ts +++ b/packages/extension/src/background/services/schedule/worker/ScheduleWorker.ts @@ -1,6 +1,6 @@ import { ALARM_VERSION } from "../../../../shared/schedule/constants" -import { IScheduleService } from "../../../../shared/schedule/IScheduleService" -import { DeepPick } from "../../../../shared/types/deepPick" +import type { IScheduleService } from "../../../../shared/schedule/IScheduleService" +import type { DeepPick } from "../../../../shared/types/deepPick" import { IS_DEV } from "../../../../shared/utils/dev" import { onInstallAndUpgrade } from "../../worker/schedule/decorators" import { pipe } from "../../worker/schedule/pipe" diff --git a/packages/extension/src/background/services/signatureReview/BackgroundOutsideSignatureReviewService.ts b/packages/extension/src/background/services/signatureReview/BackgroundOutsideSignatureReviewService.ts index 2a2e471e8..8e271642d 100644 --- a/packages/extension/src/background/services/signatureReview/BackgroundOutsideSignatureReviewService.ts +++ b/packages/extension/src/background/services/signatureReview/BackgroundOutsideSignatureReviewService.ts @@ -1,22 +1,21 @@ +import { TransactionType } from "starknet" import type { ISignatureReviewService, SimulateAndReviewPayload, } from "../../../shared/signatureReview/ISignatureReviewService" +import type { OutsideSignature } from "../../../shared/signatureReview/schema" import { outsideExecutionMessageSchema, outsideExecutionMessageSchemaV2, - OutsideSignature, } from "../../../shared/signatureReview/schema" -import type { - ITransactionReviewService, - TransactionReviewTransactions, -} from "../../../shared/transactionReview/interface" +import type { ITransactionReviewService } from "../../../shared/transactionReview/interface" +import type { InvokeTransaction } from "../../../shared/transactionReview/transactionAction.model" export default class BackgroundOutsideSignatureReviewService implements ISignatureReviewService { constructor(private transactionReviewService: ITransactionReviewService) {} - adaptSignature(signature: OutsideSignature): TransactionReviewTransactions[] { + adaptSignature(signature: OutsideSignature): InvokeTransaction { const executeFromOutsideMessageV2 = outsideExecutionMessageSchemaV2.safeParse(signature.message) @@ -28,7 +27,7 @@ export default class BackgroundOutsideSignatureReviewService calldata: call.Calldata, } }) - return [{ calls: calls, type: "INVOKE" }] + return { payload: calls, type: TransactionType.INVOKE } } // Not really needed, we know at this point that it's a safe message v1 schema, but typescript is not smart enough to pick this up @@ -37,7 +36,7 @@ export default class BackgroundOutsideSignatureReviewService ) if (!executeFromOutsideMessageV1.success) { - return [{ calls: [], type: "INVOKE" }] + return { payload: [], type: TransactionType.INVOKE } } const calls = executeFromOutsideMessageV1.data.calls?.map((call) => { @@ -47,17 +46,17 @@ export default class BackgroundOutsideSignatureReviewService calldata: call.calldata, } }) - - return [{ calls: calls, type: "INVOKE" }] + return { payload: calls, type: TransactionType.INVOKE } } + simulateAndReview({ signature, feeTokenAddress, appDomain, }: SimulateAndReviewPayload) { - const transactions = this.adaptSignature(signature) + const transaction = this.adaptSignature(signature) return this.transactionReviewService.simulateAndReview({ - transactions, + transaction, feeTokenAddress, appDomain, }) diff --git a/packages/extension/src/background/services/staking/StakingService.ts b/packages/extension/src/background/services/staking/StakingService.ts new file mode 100644 index 000000000..3cfe6b352 --- /dev/null +++ b/packages/extension/src/background/services/staking/StakingService.ts @@ -0,0 +1,252 @@ +import type { + IHttpService, + StakerInfo, + StrkStakingCalldata, + StrkStakingCalldataResponse, +} from "@argent/x-shared" +import urlJoin from "url-join" + +import type { IStakingService } from "../../../shared/staking/IStakingService" +import type { IBackgroundActionService } from "../action/IBackgroundActionService" +import type { IBackgroundInvestmentService } from "../investments/IBackgroundInvestmentService" +import type { WalletAccountType } from "../../../shared/wallet.model" +import { sanitizeAccountType } from "../../../shared/utils/sanitizeAccountType" +import type { + BuildSellOpts, + StrkStakingCalldataWithAccountType, +} from "../../../shared/staking/types" + +export class StakingService implements IStakingService { + constructor( + private readonly investmentService: IBackgroundInvestmentService, + private readonly httpService: IHttpService, + private readonly actionService: IBackgroundActionService, + ) {} + + /// Staking + async getStakeCalldata({ + investmentId, + accountAddress, + tokenAddress, + amount, + }: StrkStakingCalldata): Promise { + const url = urlJoin( + this.investmentService.investmentUrl, + "investments", + investmentId, + "buildBuyCalldata", + ) + return this.httpService.post(url, { + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + accountAddress, + assets: [ + { + tokenAddress, + amount, + }, + ], + }), + }) + } + + async stake(input: StrkStakingCalldataWithAccountType) { + const { calls } = await this.getStakeCalldata(input) + const action = await this.actionService.add( + { + type: "TRANSACTION", + payload: { + transactions: calls, + meta: { + ampliProperties: this.buildAmpliProperties( + "stake", + input.stakerInfo, + input.accountType, + ), + }, + }, + }, + { + title: "Stake STRK", + shortTitle: "Stake", + icon: "InvestSecondaryIcon", + investment: { + stakingAction: "stake", + stakerInfo: input.stakerInfo, + tokenAddress: input.tokenAddress, + amount: input.amount, + }, + }, + ) + return action.meta.hash + } + + // Unstaking + async getUnstakeCalldata( + { + investmentId, + accountAddress, + tokenAddress, + amount, + }: StrkStakingCalldataWithAccountType, + opts?: BuildSellOpts, + ): Promise { + const url = urlJoin( + this.investmentService.investmentUrl, + "investments", + investmentId, + "buildSellCalldata", + ) + return this.httpService.post(url, { + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + accountAddress, + assets: [ + { + tokenAddress, + amount, + useFullBalance: opts?.useFullBalance, + }, + ], + subsequentTransaction: opts?.subsequentTransaction, + }), + }) + } + + async initiateUnstake(input: StrkStakingCalldataWithAccountType) { + const { calls } = await this.getUnstakeCalldata(input, { + useFullBalance: true, // we enforce full balance for unstaking + }) + const action = await this.actionService.add( + { + type: "TRANSACTION", + payload: { + transactions: calls, + meta: { + ampliProperties: this.buildAmpliProperties( + "initialise withdraw", + input.stakerInfo, + input.accountType, + ), + }, + }, + }, + { + title: "Initiate withdraw STRK", + shortTitle: "Initiate withdraw", + icon: "ArrowDownPrimaryIcon", + investment: { + stakingAction: "initiateWithdraw", + stakerInfo: input.stakerInfo, + tokenAddress: input.tokenAddress, + amount: input.amount, + }, + }, + ) + return action.meta.hash + } + + async unstake(input: StrkStakingCalldataWithAccountType) { + const { calls } = await this.getUnstakeCalldata(input, { + subsequentTransaction: true, + }) + const action = await this.actionService.add( + { + type: "TRANSACTION", + payload: { + transactions: calls, + meta: { + ampliProperties: this.buildAmpliProperties( + "finalise withdraw", + input.stakerInfo, + input.accountType, + ), + }, + }, + }, + { + title: "Withdraw STRK", + shortTitle: "Withdraw", + icon: "ArrowDownPrimaryIcon", + investment: { + stakingAction: "withdraw", + stakerInfo: input.stakerInfo, + tokenAddress: input.tokenAddress, + amount: input.amount, + }, + }, + ) + return action.meta.hash + } + + async buildClaimCalldata({ + accountAddress, + investmentId, + }: StrkStakingCalldata): Promise { + const url = urlJoin( + this.investmentService.investmentUrl, + "investments", + investmentId, + "buildClaimCalldata", + ) + return this.httpService.post(url, { + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ accountAddress }), + }) + } + + async claim(input: StrkStakingCalldataWithAccountType) { + const { calls } = await this.buildClaimCalldata(input) + const action = await this.actionService.add( + { + type: "TRANSACTION", + payload: { + transactions: calls, + meta: { + ampliProperties: this.buildAmpliProperties( + "claim staked rewards", + input.stakerInfo, + input.accountType, + ), + }, + }, + }, + { + title: "Claim STRK rewards", + shortTitle: "Claim", + icon: "SparkleSecondaryIcon", + investment: { + stakingAction: "claim", + stakerInfo: input.stakerInfo, + tokenAddress: input.tokenAddress, + amount: input.amount, + }, + }, + ) + return action.meta.hash + } + + // Private + buildAmpliProperties( + type: + | "stake" + | "claim staked rewards" + | "initialise withdraw" + | "finalise withdraw", + stakerInfo: StakerInfo, + accountType: WalletAccountType, + ) { + return { + "transaction type": type, + "staking provider": stakerInfo.name, + "wallet platform": "browser extension" as const, + "account type": sanitizeAccountType(accountType), + } + } +} diff --git a/packages/extension/src/background/services/staking/index.ts b/packages/extension/src/background/services/staking/index.ts new file mode 100644 index 000000000..5725ae7b6 --- /dev/null +++ b/packages/extension/src/background/services/staking/index.ts @@ -0,0 +1,10 @@ +import { httpService } from "../../../shared/http/singleton" +import { backgroundActionService } from "../action" +import { investmentService } from "../investments" +import { StakingService } from "./StakingService" + +export const stakingService = new StakingService( + investmentService, + httpService, + backgroundActionService, +) diff --git a/packages/extension/src/background/services/token/worker/TokenWorker.test.ts b/packages/extension/src/background/services/token/worker/TokenWorker.test.ts index de6d05dd6..63f3f638f 100644 --- a/packages/extension/src/background/services/token/worker/TokenWorker.test.ts +++ b/packages/extension/src/background/services/token/worker/TokenWorker.test.ts @@ -1,20 +1,17 @@ import "fake-indexeddb/auto" -import { IRepository } from "../../../../shared/storage/__new/interface" -import { Mocked } from "vitest" -import { INetworkService } from "../../../../shared/network/service/INetworkService" -import { ITokenService } from "../../../../shared/token/__new/service/ITokenService" +import type { IRepository } from "../../../../shared/storage/__new/interface" +import type { Mocked } from "vitest" +import type { INetworkService } from "../../../../shared/network/service/INetworkService" +import type { ITokenService } from "../../../../shared/token/__new/service/ITokenService" import { TokenWorker } from "./TokenWorker" import { MockFnRepository } from "../../../../shared/storage/__new/__test__/mockFunctionImplementation" -import { WalletStorageProps } from "../../../../shared/wallet/walletStore" -import { KeyValueStorage } from "../../../../shared/storage" -import { Transaction } from "../../../../shared/transactions" -import { Token } from "../../../../shared/token/__new/types/token.model" -import { IScheduleService } from "../../../../shared/schedule/IScheduleService" -import { - emitterMock, - recoverySharedServiceMock, -} from "../../../wallet/test.utils" -import { IBackgroundUIService } from "../../ui/IBackgroundUIService" +import type { WalletStorageProps } from "../../../../shared/wallet/walletStore" +import type { KeyValueStorage } from "../../../../shared/storage" +import type { Transaction } from "../../../../shared/transactions" +import type { Token } from "../../../../shared/token/__new/types/token.model" +import type { IScheduleService } from "../../../../shared/schedule/IScheduleService" +import { recoverySharedServiceMock } from "../../../wallet/test.utils" +import type { IBackgroundUIService } from "../../ui/IBackgroundUIService" import { getMockNetwork } from "../../../../../test/network.mock" import { getMockApiTokenDetails, @@ -24,20 +21,34 @@ import { } from "../../../../../test/token.mock" import { addressSchema } from "@argent/x-shared" import { stark } from "starknet" -import { BaseWalletAccount } from "../../../../shared/wallet.model" -import { BaseTokenWithBalance } from "../../../../shared/token/__new/types/tokenBalance.model" -import { TokenPriceDetails } from "../../../../shared/token/__new/types/tokenPrice.model" +import type { BaseWalletAccount } from "../../../../shared/wallet.model" +import { SignerType } from "../../../../shared/wallet.model" +import type { BaseTokenWithBalance } from "../../../../shared/token/__new/types/tokenBalance.model" +import type { TokenPriceDetails } from "../../../../shared/token/__new/types/tokenPrice.model" import { defaultNetwork } from "../../../../shared/network" -import { IDebounceService } from "../../../../shared/debounce" +import type { IDebounceService } from "../../../../shared/debounce" import { getMockDebounceService } from "../../../../shared/debounce/mock" import { createScheduleServiceMock } from "../../../../shared/schedule/mock" -import { IActivityService } from "../../activity/IActivityService" -import { IAccountService } from "../../../../shared/account/service/accountService/IAccountService" +import type { IActivityService } from "../../activity/IActivityService" +import { getAccountIdentifier } from "../../../../shared/utils/accountIdentifier" +import type { IAccountService } from "../../../../shared/account/service/accountService/IAccountService" +import { emitterMock } from "../../../../shared/test.utils" const accountAddress1 = addressSchema.parse(stark.randomAddress()) const tokenAddress1 = addressSchema.parse(stark.randomAddress()) const tokenAddress2 = addressSchema.parse(stark.randomAddress()) +const mockSigner = { + type: SignerType.LOCAL_SECRET, + derivationPath: "m/44'/60'/0'/0/0", +} + +const accountId1 = getAccountIdentifier( + accountAddress1, + "sepolia-alpha", + mockSigner, +) + describe("TokenWorker", () => { let tokenWorker: TokenWorker let mockAccountService: Mocked @@ -61,7 +72,8 @@ describe("TokenWorker", () => { fetchTokenPricesFromBackend: vi.fn(), getCurrencyValueForTokens: vi.fn(), getToken: vi.fn(), - getTokenBalancesForAccount: vi.fn(), + getAllTokenBalancesForAccount: vi.fn(), + getTokenBalanceForAccount: vi.fn(), getTokens: vi.fn(), getTotalCurrencyBalanceForAccounts: vi.fn(), updateTokenBalances: vi.fn(), @@ -72,10 +84,13 @@ describe("TokenWorker", () => { getTokensInfoFromBackendForNetwork: vi.fn(), preferFeeToken: vi.fn(), getFeeTokenPreference: vi.fn(), + getTokenInfo: vi.fn(), } as Mocked mockAccountService = { get: vi.fn(), + getArgentWalletAccounts: vi.fn(), + emitter: emitterMock, } as unknown as Mocked mockNetworkService = { @@ -106,6 +121,7 @@ describe("TokenWorker", () => { closePopup: vi.fn(), openUi: vi.fn(), showNotification: vi.fn(), + openUiAsFloatingWindow: vi.fn(), } const [, _mockScheduleService] = createScheduleServiceMock() @@ -194,6 +210,7 @@ describe("TokenWorker", () => { it("should fetch token balances for the provided account and update the token service", async () => { // Arrange const mockAccount: BaseWalletAccount = { + id: accountId1, address: accountAddress1, networkId: "1" /* other properties */, } @@ -225,6 +242,7 @@ describe("TokenWorker", () => { it("should fetch token balances for all accounts on the current network and update the token service when no account is provided", async () => { const mockSelectedAccount: BaseWalletAccount = { + id: accountId1, address: accountAddress1, networkId: "sepolia-alpha", } @@ -251,6 +269,7 @@ describe("TokenWorker", () => { it("should not fetch token balances from the backend if the selected network isnt support by backend", async () => { const mockSelectedAccount: BaseWalletAccount = { + id: accountId1, address: accountAddress1, networkId: "insupportable", } diff --git a/packages/extension/src/background/services/token/worker/TokenWorker.ts b/packages/extension/src/background/services/token/worker/TokenWorker.ts index 11d201f42..62ca547f8 100644 --- a/packages/extension/src/background/services/token/worker/TokenWorker.ts +++ b/packages/extension/src/background/services/token/worker/TokenWorker.ts @@ -1,5 +1,10 @@ -import { isArgentNetworkId, isEqualAddress } from "@argent/x-shared" -import { IAccountService } from "../../../../shared/account/service/accountService/IAccountService" +import { + includesAddress, + isArgentNetworkId, + isEqualAddress, +} from "@argent/x-shared" +import type { IAccountService } from "../../../../shared/account/service/accountService/IAccountService" +import { AccountAddedEvent } from "../../../../shared/account/service/accountService/IAccountService" import { RefreshIntervalInSeconds } from "../../../../shared/config" import type { IDebounceService } from "../../../../shared/debounce" import { defaultNetwork } from "../../../../shared/network" @@ -17,17 +22,17 @@ import type { Token } from "../../../../shared/token/__new/types/token.model" import type { Transaction } from "../../../../shared/transactions" import { accountsEqual } from "../../../../shared/utils/accountsEqual" import { getSuccessfulTransactions } from "../../../../shared/utils/transactionSucceeded" -import { +import type { BaseWalletAccount, - isNetworkOnlyPlaceholderAccount, NetworkOnlyPlaceholderAccount, } from "../../../../shared/wallet.model" +import { isNetworkOnlyPlaceholderAccount } from "../../../../shared/wallet.model" import type { WalletStorageProps } from "../../../../shared/wallet/walletStore" import { Recovered } from "../../../wallet/recovery/IWalletRecoveryService" -import { WalletRecoverySharedService } from "../../../wallet/recovery/WalletRecoverySharedService" +import type { WalletRecoverySharedService } from "../../../wallet/recovery/WalletRecoverySharedService" +import type { NewTokenActivityPayload } from "../../activity/IActivityService" import { NewTokenActivity, - NewTokenActivityPayload, TokenActivity, type IActivityService, type TokenActivityPayload, @@ -78,6 +83,12 @@ export class TokenWorker { NewTokenActivity, this.onNewTokensDiscovered.bind(this), ) + + // Listen for new account added event + this.accountService.emitter.on( + AccountAddedEvent, + this.runUpdatesForAccount.bind(this), + ) } async getSelectedAccount() { @@ -146,6 +157,7 @@ export class TokenWorker { async runUpdatesForAccount(account: BaseWalletAccount) { void this.maybeUpdateTokensFromBackendForAccount(account) void this.updateTokenBalancesFromOnChain(account) + void this.discoverTokensFromBackendForAccount(account) } async onTransactionRepoChange(changeSet: StorageChange) { @@ -341,4 +353,49 @@ export class TokenWorker { discoveredTokensInfo, ) } + + async discoverTokensFromBackendForAccount(account: BaseWalletAccount) { + const accountTokenBalancesFromBackend = + await this.tokenService.fetchAccountTokenBalancesFromBackend(account) + + const tokensOnNetwork = await this.tokenService.getTokens( + (token) => account.networkId === token.networkId, + ) + + const knownTokenAddresses = tokensOnNetwork.map((token) => token.address) + + const discoveredTokens = accountTokenBalancesFromBackend.filter( + (accountTokenBalance) => { + return !includesAddress( + accountTokenBalance.address, + knownTokenAddresses, + ) + }, + ) + if (!discoveredTokens.length) { + return + } + + const tokensInfoOnNetwork = + await this.tokenService.getTokensInfoFromBackendForNetwork( + account.networkId, + ) + if (!tokensInfoOnNetwork) { + return + } + /** both sets of tokens are already on the same network */ + const discoveredTokensInfo: Token[] = [] + discoveredTokens.forEach((discoveredToken) => { + const tokenInfo = tokensInfoOnNetwork.find((tokenInfo) => + isEqualAddress(discoveredToken.address, tokenInfo.address), + ) + if (tokenInfo) { + discoveredTokensInfo.push({ + ...tokenInfo, + networkId: account.networkId, + }) + } + }) + await this.tokenService.addToken(discoveredTokensInfo) + } } diff --git a/packages/extension/src/background/services/tokenDetails/BackgroundTokenDetailsService.ts b/packages/extension/src/background/services/tokenDetails/BackgroundTokenDetailsService.ts new file mode 100644 index 000000000..2b67685da --- /dev/null +++ b/packages/extension/src/background/services/tokenDetails/BackgroundTokenDetailsService.ts @@ -0,0 +1,49 @@ +import type { IHttpService } from "@argent/x-shared" +import urlJoin from "url-join" +import { TokenDetailsError } from "./tokenDetailsError" +import type { + ITokensDetailsService, + TokenGraphDataApi, + TokenGraphInput, +} from "../../../shared/tokenDetails/interface" +import { apiTokenGraphDataSchema } from "../../../shared/tokenDetails/interface" + +export class TokenDetailsService implements ITokensDetailsService { + TOKENS_GRAPH_API_URL: string + private readonly TOKENS_INFO_URL: string + + constructor( + private readonly httpService: IHttpService, + TOKENS_GRAPH_API_URL: string | undefined, + TOKENS_GRAPH_INFO_URL: string | undefined, + ) { + if (!TOKENS_GRAPH_API_URL) { + throw new TokenDetailsError({ code: "TOKENS_DETAILS_API_URL" }) + } + if (!TOKENS_GRAPH_INFO_URL) { + throw new TokenDetailsError({ code: "TOKENS_GRAPH_INFO_URL" }) + } + this.TOKENS_GRAPH_API_URL = TOKENS_GRAPH_API_URL + this.TOKENS_INFO_URL = TOKENS_GRAPH_INFO_URL + } + + async fetchTokenGraph({ + tokenAddress, + currency, + timeFrame, + chain, + }: TokenGraphInput): Promise { + const endpoint = urlJoin( + this.TOKENS_GRAPH_API_URL, + tokenAddress, + `?timeframe=${timeFrame}¤cy=${currency}&chain=${chain}`, + ) + const response = await this.httpService.get(endpoint) + const parsedResponse = apiTokenGraphDataSchema.safeParse(response) + if (!parsedResponse.success) { + return + } + + return parsedResponse.data + } +} diff --git a/packages/extension/src/background/services/tokenDetails/index.ts b/packages/extension/src/background/services/tokenDetails/index.ts new file mode 100644 index 000000000..258ff22e6 --- /dev/null +++ b/packages/extension/src/background/services/tokenDetails/index.ts @@ -0,0 +1,12 @@ +import { + ARGENT_TOKENS_GRAPH_API_URL, + ARGENT_TOKENS_INFO_URL, +} from "../../../shared/api/constants" +import { httpService } from "../../../shared/http/singleton" +import { TokenDetailsService } from "./BackgroundTokenDetailsService" + +export const tokenDetailsService = new TokenDetailsService( + httpService, + ARGENT_TOKENS_GRAPH_API_URL, + ARGENT_TOKENS_INFO_URL, +) diff --git a/packages/extension/src/background/services/tokenDetails/tokenDetailsError.ts b/packages/extension/src/background/services/tokenDetails/tokenDetailsError.ts new file mode 100644 index 000000000..7fa553e65 --- /dev/null +++ b/packages/extension/src/background/services/tokenDetails/tokenDetailsError.ts @@ -0,0 +1,17 @@ +import type { BaseErrorPayload } from "@argent/x-shared" +import { BaseError } from "@argent/x-shared" + +export enum TOKEN_DETAILS_ERROR_MESSAGES { + TOKENS_DETAILS_API_URL = "TOKENS_DETAILS_API_URL is not defined", + TOKENS_GRAPH_INFO_URL = "TOKENS_GRAPH_INFO_URL is not defined", +} + +export type TokenDetailsValidationErrorMessage = + keyof typeof TOKEN_DETAILS_ERROR_MESSAGES + +export class TokenDetailsError extends BaseError { + constructor(payload: BaseErrorPayload) { + super(payload, TOKEN_DETAILS_ERROR_MESSAGES) + this.name = "TokenError" + } +} diff --git a/packages/extension/src/background/services/transactionReview/BackgroundTransactionReviewService.test.ts b/packages/extension/src/background/services/transactionReview/BackgroundTransactionReviewService.test.ts index b4e9d3222..02ed995a6 100644 --- a/packages/extension/src/background/services/transactionReview/BackgroundTransactionReviewService.test.ts +++ b/packages/extension/src/background/services/transactionReview/BackgroundTransactionReviewService.test.ts @@ -1,12 +1,13 @@ -import { Address, IHttpService } from "@argent/x-shared" -import { Account, EstimateFee } from "starknet" -import { Mocked, describe, expect, test, vi } from "vitest" +import type { Address, IHttpService, TransactionAction } from "@argent/x-shared" +import type { Account, EstimateFee } from "starknet" +import { TransactionType } from "starknet" +import type { Mocked } from "vitest" +import { describe, expect, test, vi } from "vitest" import type { KeyValueStorage } from "../../../shared/storage" import type { ITransactionReviewLabelsStore, ITransactionReviewWarningsStore, - TransactionReviewTransactions, } from "../../../shared/transactionReview/interface" import type { WalletAccount } from "../../../shared/wallet.model" import type { Wallet } from "../../wallet" @@ -15,12 +16,16 @@ import type { ITransactionReviewWorker } from "./worker/ITransactionReviewWorker import sendFixture from "../../../shared/transactionReview/__fixtures__/send.json" import simulationErrorUnexpectedFixture from "../../../shared/transactionReview/__fixtures__/simulation-error-unexpected.json" +import { getRandomAccountIdentifier } from "../../../shared/utils/accountIdentifier" +import type { BaseStarknetAccount } from "../../../shared/starknetAccount/base" +import type { INonceManagementService } from "../../nonceManagement/INonceManagementService" describe("BackgroundTransactionReviewService", () => { const makeService = () => { const walletSingleton = { getSelectedAccount: vi.fn(), getSelectedStarknetAccount: vi.fn(), + getStarknetAccount: vi.fn(), } as unknown as Mocked const httpService = { @@ -40,6 +45,10 @@ describe("BackgroundTransactionReviewService", () => { subscribe: vi.fn(), } as unknown as Mocked> + const nonceManagementService = { + getNonce: vi.fn(), + } as unknown as Mocked + const transactionReviewWorker = { maybeUpdateLabels: vi.fn(), } as unknown as Mocked @@ -48,6 +57,7 @@ describe("BackgroundTransactionReviewService", () => { new BackgroundTransactionReviewService( walletSingleton, httpService, + nonceManagementService, transactionReviewLabelsStore, transactionReviewWarningsStore, transactionReviewWorker, @@ -56,6 +66,7 @@ describe("BackgroundTransactionReviewService", () => { const networkId = "sepolia-alpha" walletSingleton.getSelectedAccount.mockResolvedValue({ + id: getRandomAccountIdentifier("0x123"), address: "0x123", networkId, network: { @@ -67,28 +78,41 @@ describe("BackgroundTransactionReviewService", () => { cairoVersion: "1", getNonce: vi.fn(), getChainId: vi.fn(), - estimateFee: vi.fn(), + estimateInvokeFee: vi.fn(), } as unknown as Mocked - starknetAccount.estimateFee.mockResolvedValue({ + const estimateFee = { gas_consumed: 123n, gas_price: 456n, - } as EstimateFee) + } as EstimateFee + + starknetAccount.estimateInvokeFee.mockResolvedValue(estimateFee) walletSingleton.getSelectedStarknetAccount.mockResolvedValue( starknetAccount, ) + const baseStarknetAccount = { + ...starknetAccount, + estimateFeeBulk: vi.fn(), + } as unknown as Mocked + + baseStarknetAccount.estimateInvokeFee.mockResolvedValue(estimateFee) + + walletSingleton.getStarknetAccount.mockResolvedValue(baseStarknetAccount) + const feeTokenAddress: Address = "0x123456" return { backgroundTransactionReviewService, walletSingleton, httpService, + nonceManagementService, transactionReviewLabelsStore, transactionReviewWorker, feeTokenAddress, starknetAccount, + baseStarknetAccount, } } describe("simulateAndReview", () => { @@ -99,23 +123,21 @@ describe("BackgroundTransactionReviewService", () => { backgroundTransactionReviewService, httpService, feeTokenAddress, + nonceManagementService, } = makeService() - const transactions: TransactionReviewTransactions[] = [ - { - type: "INVOKE", - calls: [], - }, - ] + const transaction: TransactionAction = { + type: TransactionType.INVOKE, + payload: [], + } httpService.post.mockResolvedValueOnce(sendFixture) - + nonceManagementService.getNonce.mockResolvedValueOnce("0x2") const result = await backgroundTransactionReviewService.simulateAndReview({ - transactions, + transaction, feeTokenAddress, }) - expect(result).toMatchObject(sendFixture) expect(result.enrichedFeeEstimation).toMatchInlineSnapshot(` @@ -142,33 +164,32 @@ describe("BackgroundTransactionReviewService", () => { httpService, feeTokenAddress, starknetAccount, + nonceManagementService, } = makeService() - const transactions: TransactionReviewTransactions[] = [ - { - type: "INVOKE", - calls: [], - }, - ] + const transaction = { + type: TransactionType.INVOKE as const, + payload: [], + } httpService.post.mockResolvedValueOnce( simulationErrorUnexpectedFixture, ) - const fallbackToOnchainFeeEstimationSpy = vi.spyOn( + const getEnrichedFeeEstimationSpy = vi.spyOn( backgroundTransactionReviewService, - "fallbackToOnchainFeeEstimation", + "getEnrichedFeeEstimation", ) - const result = - await backgroundTransactionReviewService.simulateAndReview({ - transactions, - feeTokenAddress, - }) + nonceManagementService.getNonce.mockResolvedValueOnce("0x2") + await backgroundTransactionReviewService.simulateAndReview({ + transaction, + feeTokenAddress, + }) - expect(fallbackToOnchainFeeEstimationSpy).not.toHaveBeenCalledOnce() + expect(getEnrichedFeeEstimationSpy).not.toHaveBeenCalledOnce() - expect(starknetAccount.estimateFee).not.toHaveBeenCalledOnce() + expect(starknetAccount.estimateInvokeFee).not.toHaveBeenCalledOnce() }) }) describe("when backend fails with error", () => { @@ -177,15 +198,16 @@ describe("BackgroundTransactionReviewService", () => { backgroundTransactionReviewService, httpService, feeTokenAddress, - starknetAccount, + baseStarknetAccount, + nonceManagementService, } = makeService() - const transactions: TransactionReviewTransactions[] = [ - { - type: "INVOKE", - calls: [], - }, - ] + const transaction = { + type: TransactionType.INVOKE as const, + payload: [], + } + + nonceManagementService.getNonce.mockResolvedValueOnce("0x2") httpService.post.mockRejectedValueOnce(new Error()) @@ -196,13 +218,13 @@ describe("BackgroundTransactionReviewService", () => { const result = await backgroundTransactionReviewService.simulateAndReview({ - transactions, + transaction, feeTokenAddress, }) expect(fallbackToOnchainFeeEstimationSpy).toHaveBeenCalledOnce() - expect(starknetAccount.estimateFee).toHaveBeenCalledOnce() + expect(baseStarknetAccount.estimateInvokeFee).toHaveBeenCalledOnce() expect(result).toMatchObject({ isBackendDown: true, diff --git a/packages/extension/src/background/services/transactionReview/BackgroundTransactionReviewService.ts b/packages/extension/src/background/services/transactionReview/BackgroundTransactionReviewService.ts index 9abb3d028..7a30fa083 100644 --- a/packages/extension/src/background/services/transactionReview/BackgroundTransactionReviewService.ts +++ b/packages/extension/src/background/services/transactionReview/BackgroundTransactionReviewService.ts @@ -1,73 +1,60 @@ import urlJoin from "url-join" +import type { Address, TransactionAction } from "@argent/x-shared" import { - Address, ensureArray, estimatedFeeToMaxResourceBounds, getEstimatedFeeFromSimulationAndRespectWatermarkFee, - getPayloadFromTransaction, getTxVersionFromFeeToken, hexSchema, type IHttpService, } from "@argent/x-shared" -import { - Account, - BigNumberish, - CairoVersion, - Call, - Calldata, - Invocations, - json, - num, - TransactionType, -} from "starknet" +import type { Account, BigNumberish, Call, Invocations } from "starknet" +import { json, num, TransactionType } from "starknet" import { base64 } from "@scure/base" -import { +import type { EnrichedSimulateAndReview, EstimatedFees, + SimulateAndReview, +} from "@argent/x-shared/simulation" +import { getErrorMessageAndLabelFromSimulation, isTransactionSimulationError, - SimulateAndReview, simulateAndReviewSchema, } from "@argent/x-shared/simulation" import { ARGENT_TRANSACTION_REVIEW_API_BASE_URL } from "../../../shared/api/constants" import { AccountError } from "../../../shared/errors/account" import { ReviewError } from "../../../shared/errors/review" import { isArgentNetwork } from "../../../shared/network/utils" -import { KeyValueStorage } from "../../../shared/storage" +import type { KeyValueStorage } from "../../../shared/storage" import type { ITransactionReviewLabelsStore, ITransactionReviewService, ITransactionReviewWarningsStore, - TransactionReviewTransactions, } from "../../../shared/transactionReview/interface" -import type { StarknetTransactionTypes } from "../../../shared/transactions" import { addEstimatedFee, getEstimatedFees, } from "../../../shared/transactionSimulation/fees/estimatedFeesRepository" -import { getNonce } from "../../nonce" -import { Wallet } from "../../wallet" -import { ITransactionReviewWorker } from "./worker/ITransactionReviewWorker" -import { BaseWalletAccount, WalletAccount } from "../../../shared/wallet.model" +import type { Wallet } from "../../wallet" +import type { ITransactionReviewWorker } from "./worker/ITransactionReviewWorker" +import type { + BaseWalletAccount, + WalletAccount, +} from "../../../shared/wallet.model" import { base64url } from "@scure/base" import { deflateSync } from "fflate" import { browserExtensionSentryWithScope } from "../../../shared/sentry/scope" +import { walletAccountToArgentAccount } from "../../../shared/utils/isExternalAccount" import { urlWithQuery } from "../../../shared/utils/url" - -interface ApiTransactionReviewV2RequestBody { - transactions: Array<{ - type: StarknetTransactionTypes - chainId: string - cairoVersion: CairoVersion - nonce: string - version: string - account: string - calls?: Call[] - calldata?: Calldata - }> -} +import type { INonceManagementService } from "../../nonceManagement/INonceManagementService" +import type { + AccountDeployTransaction, + InvokeTransaction, +} from "../../../shared/transactionReview/transactionAction.model" +import { assertNever } from "../../../shared/utils/assertNever" +import type { ApiTransaction } from "./types" const simulateAndReviewEndpoint = urlJoin( ARGENT_TRANSACTION_REVIEW_API_BASE_URL || "", @@ -80,6 +67,7 @@ export default class BackgroundTransactionReviewService constructor( private wallet: Wallet, private httpService: IHttpService, + private nonceManagementService: INonceManagementService, private readonly labelsStore: KeyValueStorage, private readonly warningsStore: KeyValueStorage, private worker: ITransactionReviewWorker, @@ -87,12 +75,12 @@ export default class BackgroundTransactionReviewService private async fetchFeesOnchain({ starknetAccount, - calls, + action, isDeployed, feeTokenAddress, }: { starknetAccount: Account - calls: Call[] + action: TransactionAction isDeployed: boolean feeTokenAddress: Address }) { @@ -120,13 +108,11 @@ export default class BackgroundTransactionReviewService const bulkTransactions: Invocations = [ { type: TransactionType.DEPLOY_ACCOUNT, - payload: - await this.wallet.getAccountDeploymentPayload(selectedAccount), - }, - { - type: TransactionType.INVOKE, - payload: calls, + payload: await this.wallet.getAccountDeploymentPayload( + walletAccountToArgentAccount(selectedAccount), + ), }, + action, ] const [deployEstimate, txEstimate] = await starknetAccount .estimateFeeBulk(bulkTransactions, { @@ -165,10 +151,7 @@ export default class BackgroundTransactionReviewService } } else { const { gas_consumed, gas_price, data_gas_consumed, data_gas_price } = - await starknetAccount.estimateFee(calls, { - skipValidate: true, - version, - }) + await this.getEstimateFromAction(action, starknetAccount) if (!gas_consumed || !gas_price) { throw new ReviewError({ @@ -186,10 +169,7 @@ export default class BackgroundTransactionReviewService } } - await addEstimatedFee(fees, { - type: TransactionType.INVOKE, - payload: calls, - }) + await addEstimatedFee(fees, action) return fees } catch (error) { @@ -200,70 +180,91 @@ export default class BackgroundTransactionReviewService } } - private getCallsFromTx(tx: TransactionReviewTransactions) { - let calls - if (tx.calls) { - calls = ensureArray(tx.calls) + private getEstimateFromAction(action: TransactionAction, account: Account) { + switch (action.type) { + case TransactionType.INVOKE: + return account.estimateInvokeFee(action.payload) + case TransactionType.DEPLOY: + return account.estimateDeployFee(action.payload) + case TransactionType.DECLARE: + return account.estimateDeclareFee(action.payload) + case TransactionType.DEPLOY_ACCOUNT: + return account.estimateAccountDeployFee(action.payload) + default: + assertNever(action) + throw new ReviewError({ code: "INVALID_TRANSACTION_ACTION" }) + } + } + + private getCallsFromTx(tx: InvokeTransaction) { + let calls: Call[] = [] + if (tx.payload) { + calls = ensureArray(tx.payload) } return calls } - private async getEnrichedFeeEstimation( - initialTransactions: TransactionReviewTransactions[], + async getEnrichedFeeEstimation( + txAction: TransactionAction, simulateAndReviewResult: SimulateAndReview, - isDeploymentTransaction: boolean, ): Promise { const fee = getEstimatedFeeFromSimulationAndRespectWatermarkFee( simulateAndReviewResult, ) - await addEstimatedFee(fee, { - type: TransactionType.INVOKE, - payload: initialTransactions[isDeploymentTransaction ? 1 : 0].calls ?? [], - }) + await addEstimatedFee(fee, txAction) return fee } async simulateAndReview({ - transactions, + transaction, + accountDeployTransaction, feeTokenAddress, appDomain, + maxSendEstimate, }: { - transactions: TransactionReviewTransactions[] + transaction: TransactionAction feeTokenAddress: Address + accountDeployTransaction?: AccountDeployTransaction appDomain?: string + maxSendEstimate?: boolean }): Promise { const selectedAccount = await this.wallet.getSelectedAccount() - const account = await this.wallet.getSelectedStarknetAccount() - const isDeploymentTransaction = transactions.some( - (tx) => tx.type === "DEPLOY_ACCOUNT", - ) - if (!selectedAccount) { throw new AccountError({ code: "NOT_SELECTED" }) } + const account = await this.wallet.getStarknetAccount(selectedAccount.id) + + // save some ms by starting the nonce check early + const noncePromise = this.nonceManagementService.getNonce( + selectedAccount.id, + ) + + const isDeploymentTransaction = Boolean(accountDeployTransaction) let isDelayedTransaction = false try { - const multisig = await this.wallet.getMultisigAccount(selectedAccount) + const multisig = await this.wallet.getMultisigAccount(selectedAccount.id) isDelayedTransaction = selectedAccount.type === "multisig" && multisig && multisig.threshold > 1 - } catch (e) { + } catch { // do nothing } try { - if (!isArgentNetwork(selectedAccount?.network)) { - // If it's not an argent network we fallback to onchain fee estimation + if ( + !isArgentNetwork(selectedAccount?.network) || // If it's not an argent network we fallback to onchain fee estimation + !this.isInvokeTransaction(transaction) // or if it's not an invoke transaction, because backend only supports invoke transactions + ) { console.warn( `Falling back to onchain fee estimation as ${selectedAccount?.network.id} is not an argent network`, ) return this.fallbackToOnchainFeeEstimation({ account, - transactions, + transaction, isDeploymentTransaction, feeTokenAddress, }) @@ -271,9 +272,9 @@ export default class BackgroundTransactionReviewService const version = getTxVersionFromFeeToken(feeTokenAddress) - const nonce = isDeploymentTransaction - ? "0x0" - : await getNonce(selectedAccount, account) + const transactionNonce = isDeploymentTransaction + ? "0x1" + : await noncePromise if (!("getChainId" in account)) { throw new AccountError({ @@ -288,24 +289,30 @@ export default class BackgroundTransactionReviewService } const chainId = await account.getChainId() - const body: ApiTransactionReviewV2RequestBody = { - transactions: transactions.map((transaction) => - getPayloadFromTransaction({ - transaction, - nonce, - version, - chainId, - appDomain, - isDeploymentTransaction, - cairoVersion: account.cairoVersion, - address: account.address, - }), - ), + const transactions = this.getPayloadFromInvokeTransaction({ + transaction, + accountDeployTransaction, + account, + nonce: transactionNonce, + version, + chainId, + appDomain, + }) + + const queryParams: Record = {} + + if (isDelayedTransaction) { + queryParams.delayedTransactions = true + } + + if (maxSendEstimate) { + queryParams.maxSendEstimate = true } - const endpointWithParams = isDelayedTransaction - ? urlWithQuery(simulateAndReviewEndpoint, { delayedTransactions: true }) - : simulateAndReviewEndpoint + const endpointWithParams = urlWithQuery( + simulateAndReviewEndpoint, + queryParams, + ) const result = await this.httpService.post( endpointWithParams, @@ -314,7 +321,7 @@ export default class BackgroundTransactionReviewService Accept: "application/json", "Content-Type": "application/json", }, - body: JSON.stringify(body), + body: JSON.stringify({ transactions }), }, simulateAndReviewSchema, ) @@ -322,7 +329,7 @@ export default class BackgroundTransactionReviewService // if there is a simulation error then there is also no actual simulation // or fee information, and no way to proceed with fee estimation // returning the result will surface the error to the user in the ui - const hasSimulationError = result.transactions.some((transaction) => + const hasSimulationError = result.transactions?.some((transaction) => isTransactionSimulationError(transaction), ) if (hasSimulationError) { @@ -363,9 +370,8 @@ export default class BackgroundTransactionReviewService } const enrichedFeeEstimation = await this.getEnrichedFeeEstimation( - transactions, + transaction, result, - isDeploymentTransaction, ) return { ...result, @@ -374,7 +380,7 @@ export default class BackgroundTransactionReviewService } catch (e) { console.error(e) return this.fallbackToOnchainFeeEstimation({ - transactions, + transaction, account, isDeploymentTransaction, feeTokenAddress, @@ -382,31 +388,80 @@ export default class BackgroundTransactionReviewService } } + private getPayloadFromInvokeTransaction({ + transaction, + accountDeployTransaction, + account, + nonce, + version, + chainId, + appDomain, + }: { + transaction: InvokeTransaction + accountDeployTransaction?: AccountDeployTransaction + account: Account + nonce: string + version: string + chainId: string + appDomain?: string + }) { + const transactions: ApiTransaction[] = [] + + if (accountDeployTransaction) { + const { constructorCalldata, addressSalt, classHash } = + accountDeployTransaction.payload + transactions.push({ + type: TransactionType.DEPLOY_ACCOUNT, + chainId, + account: account.address, + nonce: "0x0", + version, + cairoVersion: account.cairoVersion, + calldata: constructorCalldata, + salt: hexSchema.parse(addressSalt), + classHash: hexSchema.parse(classHash), + appDomain, + }) + } + + const calls = ensureArray(transaction.payload) + transactions.push({ + type: transaction.type, + calls, + account: account.address, + nonce, + version, + chainId, + cairoVersion: account.cairoVersion, + appDomain, + // appDomain: "https://starknetkit-blacked-listed.vercel.app", // to simulate blacklisted domain + }) + + return transactions + } + + isInvokeTransaction( + transaction: TransactionAction, + ): transaction is InvokeTransaction { + return transaction.type === TransactionType.INVOKE + } + async fallbackToOnchainFeeEstimation({ - transactions, + transaction, account, isDeploymentTransaction, feeTokenAddress, }: { - transactions: TransactionReviewTransactions[] + transaction: TransactionAction account: Account isDeploymentTransaction: boolean feeTokenAddress: Address }) { try { - const invokeCalls = isDeploymentTransaction - ? this.getCallsFromTx(transactions[1]) - : this.getCallsFromTx(transactions[0]) - - if (!invokeCalls) { - throw new ReviewError({ - code: "NO_CALLS_FOUND", - }) - } // Backend is failing we use the fallback method to estimate fees const enrichedFeeEstimation = await this.fetchFeesOnchain({ starknetAccount: account, - calls: invokeCalls, + action: transaction, isDeployed: !isDeploymentTransaction, feeTokenAddress, }) @@ -424,69 +479,24 @@ export default class BackgroundTransactionReviewService } } - async getTransactionHash( - baseAccount: BaseWalletAccount, - calls: Call | Call[], - estimatedFee?: EstimatedFees, - providedNonce?: BigNumberish, - ) { - const transactions = ensureArray(calls) - const account = await this.wallet.getAccount(baseAccount) - - if (!account) { - throw new AccountError({ code: "NOT_FOUND" }) - } - - const starknetAccount = await this.wallet.getStarknetAccount(account) - - const details = await this.buildTransactionDetails( - account, - transactions, - estimatedFee, - providedNonce, - ) - - if (!details) { - return null - } - - const { nonce, resourceBounds, maxFee, version } = details - - let txHash - - try { - txHash = await starknetAccount.getInvokeTransactionHash(transactions, { - nonce, - version, - maxFee, - resourceBounds, - }) - } catch (error) { - console.error(error) - return null - } - - return hexSchema.parse(txHash) - } - - async buildTransactionPayload( + async buildInvokeTransactionPayload( baseAccount: BaseWalletAccount, calls: Call | Call[], estimatedFee?: EstimatedFees, providedNonce?: BigNumberish, ) { const transactions = ensureArray(calls) - const account = await this.wallet.getAccount(baseAccount) + const account = await this.wallet.getAccount(baseAccount.id) if (!account) { throw new AccountError({ code: "NOT_FOUND" }) } - const starknetAccount = await this.wallet.getStarknetAccount(account) + const starknetAccount = await this.wallet.getStarknetAccount(account.id) const details = await this.buildTransactionDetails( account, - transactions, + { type: TransactionType.INVOKE, payload: transactions }, estimatedFee, providedNonce, ) @@ -495,11 +505,11 @@ export default class BackgroundTransactionReviewService return null } - const { nonce, resourceBounds, maxFee, version } = details + const { nonce, transactionFees, version } = details const tx = await starknetAccount.buildInvokeTransactionPayload( transactions, - { nonce, version, maxFee, resourceBounds }, + { nonce, version, ...transactionFees }, ) return tx @@ -512,7 +522,7 @@ export default class BackgroundTransactionReviewService providedNonce?: BigNumberish, ) { try { - const tx = await this.buildTransactionPayload( + const tx = await this.buildInvokeTransactionPayload( baseAccount, calls, estimatedFee, @@ -539,18 +549,11 @@ export default class BackgroundTransactionReviewService private async buildTransactionDetails( account: WalletAccount, - transactions: Call[], + transaction: TransactionAction, estimatedFee?: EstimatedFees, providedNonce?: BigNumberish, ) { - const starknetAccount = await this.wallet.getStarknetAccount(account) - - const fees = - estimatedFee ?? - (await getEstimatedFees({ - type: TransactionType.INVOKE, - payload: transactions, - })) + const fees = estimatedFee ?? (await getEstimatedFees(transaction)) if (!fees) { return null } @@ -561,11 +564,19 @@ export default class BackgroundTransactionReviewService ? num.toHex(1) : providedNonce ? num.toHex(providedNonce) - : await getNonce(account, starknetAccount) + : await this.nonceManagementService.getNonce(account.id) + + const transactionFees = estimatedFeeToMaxResourceBounds(fees.transactions) + + const deploymentFees = fees.deployment + ? estimatedFeeToMaxResourceBounds(fees.deployment) + : undefined + return { nonce, version, - ...estimatedFeeToMaxResourceBounds(fees.transactions), + transactionFees, + deploymentFees, } } diff --git a/packages/extension/src/background/services/transactionReview/index.ts b/packages/extension/src/background/services/transactionReview/index.ts index ec6402e2e..98e8e3fd8 100644 --- a/packages/extension/src/background/services/transactionReview/index.ts +++ b/packages/extension/src/background/services/transactionReview/index.ts @@ -6,11 +6,13 @@ import { transactionReviewWarningsStore, } from "../../../shared/transactionReview/store" import { transactionReviewWorker } from "./worker" +import { nonceManagementService } from "../../nonceManagement" export const backgroundTransactionReviewService = new BackgroundTransactionReviewService( walletSingleton, httpService, + nonceManagementService, transactionReviewLabelsStore, transactionReviewWarningsStore, transactionReviewWorker, diff --git a/packages/extension/src/background/services/transactionReview/types.ts b/packages/extension/src/background/services/transactionReview/types.ts new file mode 100644 index 000000000..6533beb64 --- /dev/null +++ b/packages/extension/src/background/services/transactionReview/types.ts @@ -0,0 +1,31 @@ +import type { Hex } from "@argent/x-shared" +import type { CairoVersion, Call, Calldata, TransactionType } from "starknet" + +interface CommonApiTransactionProps { + type: TransactionType + chainId: string + cairoVersion: CairoVersion + version: string + account: string + appDomain?: string +} + +export type ApiTransaction = CommonApiTransactionProps & + ( + | { + type: TransactionType.DEPLOY_ACCOUNT + nonce: "0x0" + calldata: Calldata + classHash: Hex + salt: Hex + } + | { + type: TransactionType.INVOKE + nonce: string + calls: Call[] + } + ) + +export interface ApiTransactionReviewV2RequestBody { + transactions: Array +} diff --git a/packages/extension/src/background/services/transactionReview/worker/TransactionReviewWorker.test.ts b/packages/extension/src/background/services/transactionReview/worker/TransactionReviewWorker.test.ts index 870eb57be..a6ac3aa2d 100644 --- a/packages/extension/src/background/services/transactionReview/worker/TransactionReviewWorker.test.ts +++ b/packages/extension/src/background/services/transactionReview/worker/TransactionReviewWorker.test.ts @@ -1,17 +1,17 @@ import { describe, expect, test, vi } from "vitest" import type { IHttpService } from "@argent/x-shared" -import { KeyValueStorage } from "../../../../shared/storage" +import type { KeyValueStorage } from "../../../../shared/storage" import type { ITransactionReviewLabelsStore, ITransactionReviewWarningsStore, } from "../../../../shared/transactionReview/interface" import { TransactionReviewWorker } from "./TransactionReviewWorker" import type { IBackgroundUIService } from "../../ui/IBackgroundUIService" -import { emitterMock } from "../../../wallet/test.utils" import { delay } from "../../../../shared/utils/delay" import { getMockDebounceService } from "../../../../shared/debounce/mock" import { createScheduleServiceMock } from "../../../../shared/schedule/mock" +import { emitterMock } from "../../../../shared/test.utils" describe("TransactionReviewWorker", () => { const makeService = () => { diff --git a/packages/extension/src/background/services/transactionTracker/BaseTransactionTrackingService.test.ts b/packages/extension/src/background/services/transactionTracker/BaseTransactionTrackingService.test.ts index a04f9dfb6..6f489971f 100644 --- a/packages/extension/src/background/services/transactionTracker/BaseTransactionTrackingService.test.ts +++ b/packages/extension/src/background/services/transactionTracker/BaseTransactionTrackingService.test.ts @@ -1,4 +1,4 @@ -import { BaseTransaction } from "../../../shared/transactions/interface" +import type { BaseTransaction } from "../../../shared/transactions/interface" import { BaseTransactionTrackingService } from "./BaseTransactionTrackingService" class TestTransactionService extends BaseTransactionTrackingService< diff --git a/packages/extension/src/background/services/transactionTracker/BaseTransactionTrackingService.ts b/packages/extension/src/background/services/transactionTracker/BaseTransactionTrackingService.ts index 0a64d9634..7dc4b2f1b 100644 --- a/packages/extension/src/background/services/transactionTracker/BaseTransactionTrackingService.ts +++ b/packages/extension/src/background/services/transactionTracker/BaseTransactionTrackingService.ts @@ -1,4 +1,4 @@ -import { BaseTransaction } from "../../../shared/transactions/interface" +import type { BaseTransaction } from "../../../shared/transactions/interface" type TxStatusUpdate = { transaction: H diff --git a/packages/extension/src/background/services/transactionTracker/worker/TransactionTrackerWorker.test.ts b/packages/extension/src/background/services/transactionTracker/worker/TransactionTrackerWorker.test.ts index d26dc951b..188c0b33d 100644 --- a/packages/extension/src/background/services/transactionTracker/worker/TransactionTrackerWorker.test.ts +++ b/packages/extension/src/background/services/transactionTracker/worker/TransactionTrackerWorker.test.ts @@ -5,19 +5,19 @@ import { } from "./TransactionTrackerWorker" import { mockChainService } from "../../../../shared/chain/service/__test__/mock" import { MockFnRepository } from "../../../../shared/storage/__new/__test__/mockFunctionImplementation" -import { +import type { ExecutionStatus, ExtendedFinalityStatus, Transaction, } from "../../../../shared/transactions" import { createScheduleServiceMock } from "../../../../shared/schedule/mock" -import { IScheduleService } from "../../../../shared/schedule/IScheduleService" -import { IBackgroundUIService } from "../../ui/IBackgroundUIService" -import { IDebounceService } from "../../../../shared/debounce" -import { emitterMock } from "../../../wallet/test.utils" +import type { IScheduleService } from "../../../../shared/schedule/IScheduleService" +import type { IBackgroundUIService } from "../../ui/IBackgroundUIService" +import type { IDebounceService } from "../../../../shared/debounce" import { getMockDebounceService } from "../../../../shared/debounce/mock" import { delay } from "../../../../shared/utils/delay" import { getTransactionStatus } from "../../../../shared/transactions/utils" +import { emitterMock } from "../../../../shared/test.utils" vi.mock("../../../../shared/utils/delay") vi.mock("../../../../shared/transactions/utils") @@ -41,6 +41,7 @@ describe("TransactionTrackerWorker", () => { closePopup: vi.fn(), openUi: vi.fn(), showNotification: vi.fn(), + openUiAsFloatingWindow: vi.fn(), } mockDebounceService = getMockDebounceService() @@ -223,7 +224,7 @@ describe("TransactionTrackerWorker", () => { }) expect(transactionTracker.syncTransactionRepo).toHaveBeenCalled() - expect(delay).toHaveBeenCalledWith(3000) // First delay + expect(delay).toHaveBeenCalledWith(1000) // First delay }) it("should not trigger sync for non-RECEIVED transactions", async () => { @@ -289,8 +290,8 @@ describe("TransactionTrackerWorker", () => { // Wait for all promises in the microtask queue to resolve await new Promise(process.nextTick) - expect(transactionTracker.syncTransactionRepo).toHaveBeenCalledTimes(6) // Once for each delay - expect(delay).toHaveBeenCalledTimes(6) + expect(transactionTracker.syncTransactionRepo).toHaveBeenCalledTimes(10) // Once for each delay + expect(delay).toHaveBeenCalledTimes(10) expect(transactionTracker.emitter.emit).not.toHaveBeenCalled() }) diff --git a/packages/extension/src/background/services/transactionTracker/worker/TransactionTrackerWorker.ts b/packages/extension/src/background/services/transactionTracker/worker/TransactionTrackerWorker.ts index 9ef605ef0..3c71e2db3 100644 --- a/packages/extension/src/background/services/transactionTracker/worker/TransactionTrackerWorker.ts +++ b/packages/extension/src/background/services/transactionTracker/worker/TransactionTrackerWorker.ts @@ -1,11 +1,9 @@ -import { - BaseTransactionTrackingService, - TransactionTrackingService, -} from "../BaseTransactionTrackingService" +import type { TransactionTrackingService } from "../BaseTransactionTrackingService" +import { BaseTransactionTrackingService } from "../BaseTransactionTrackingService" -import { IScheduleService } from "../../../../shared/schedule/IScheduleService" -import { IChainService } from "../../../../shared/chain/service/IChainService" -import { +import type { IScheduleService } from "../../../../shared/schedule/IScheduleService" +import type { IChainService } from "../../../../shared/chain/service/IChainService" +import type { BaseTransaction, TransactionStatus, } from "../../../../shared/transactions/interface" @@ -14,11 +12,9 @@ import { getTransactionStatus, identifierToBaseTransaction, } from "../../../../shared/transactions/utils" -import { IRepository } from "../../../../shared/storage/__new/interface" -import { - Transaction, - getInFlightTransactions, -} from "../../../../shared/transactions" +import type { IRepository } from "../../../../shared/storage/__new/interface" +import type { Transaction } from "../../../../shared/transactions" +import { getInFlightTransactions } from "../../../../shared/transactions" import uniqWith from "lodash-es/uniqWith" import { accountsEqual } from "../../../../shared/utils/accountsEqual" import { getTransactionHistory } from "../../../transactions/sources/voyager" @@ -27,10 +23,10 @@ import { accountService } from "../../../../shared/account/service" import { RefreshIntervalInSeconds } from "../../../../shared/config" import { pipe } from "../../worker/schedule/pipe" import { everyWhenOpen } from "../../worker/schedule/decorators" -import { IBackgroundUIService } from "../../ui/IBackgroundUIService" -import { IDebounceService } from "../../../../shared/debounce" +import type { IBackgroundUIService } from "../../ui/IBackgroundUIService" +import type { IDebounceService } from "../../../../shared/debounce" import { delay } from "../../../../shared/utils/delay" -import Emittery from "emittery" +import type Emittery from "emittery" function isFinalStatus(status: TransactionStatus): boolean { return status.status === "confirmed" || status.status === "failed" @@ -42,8 +38,8 @@ export type Events = { [TransactionStatusChanged]: { transactions: string[] } } -// Initial waiting time is 3s, then we do 2s intervals for 5 times, after that we fallback to the 20s interval -const DELAYS = [3000, 2000, 2000, 2000, 2000, 2000] +// Initial waiting time is 1s, then we do 1s intervals for 5 times, 2s intervals for 5 times, after that we fall back to the 20s interval +const DELAYS = [...Array(5).fill(1000), ...Array(5).fill(2000)] export class TransactionTrackerWorker extends BaseTransactionTrackingService @@ -225,7 +221,7 @@ export class TransactionTrackerWorker } } - syncTxRepoWithDelays() + void syncTxRepoWithDelays() } } catch (error) { // Silently fail diff --git a/packages/extension/src/background/services/transactionTracker/worker/index.ts b/packages/extension/src/background/services/transactionTracker/worker/index.ts index ac3e13c8a..2dcaa5182 100644 --- a/packages/extension/src/background/services/transactionTracker/worker/index.ts +++ b/packages/extension/src/background/services/transactionTracker/worker/index.ts @@ -1,7 +1,8 @@ import { starknetChainService } from "../../../../shared/chain/service" import { chromeScheduleService } from "../../../../shared/schedule" import { transactionsRepo } from "../../../../shared/transactions/store" -import { TransactionTrackerWorker, Events } from "./TransactionTrackerWorker" +import type { Events } from "./TransactionTrackerWorker" +import { TransactionTrackerWorker } from "./TransactionTrackerWorker" import { debounceService } from "../../../../shared/debounce" import { backgroundUIService } from "../../ui" import Emittery from "emittery" diff --git a/packages/extension/src/background/services/transactions/worker/TransactionsWorker.ts b/packages/extension/src/background/services/transactions/worker/TransactionsWorker.ts index c6d62a2a6..b714fbf9a 100644 --- a/packages/extension/src/background/services/transactions/worker/TransactionsWorker.ts +++ b/packages/extension/src/background/services/transactions/worker/TransactionsWorker.ts @@ -17,7 +17,7 @@ export class TransactionsWorker { /** transactions which already existed in the store, and have now changed status */ if (addedOrUpdatedTransactions.length > 0) { - runAddedOrUpdatedHandlers(addedOrUpdatedTransactions) + void runAddedOrUpdatedHandlers(addedOrUpdatedTransactions) } }) } diff --git a/packages/extension/src/background/services/ui/BackgroundUIService.ts b/packages/extension/src/background/services/ui/BackgroundUIService.ts index 91ccf900d..7d57e46bb 100644 --- a/packages/extension/src/background/services/ui/BackgroundUIService.ts +++ b/packages/extension/src/background/services/ui/BackgroundUIService.ts @@ -1,4 +1,4 @@ -import Emittery from "emittery" +import type Emittery from "emittery" import browser from "webextension-polyfill" import { urlWithQuery } from "@argent/x-shared" @@ -141,7 +141,7 @@ export default class BackgroundUIService implements IBackgroundUIService { return await this.sendMessageToClientUIService({ type: "HAS_POPUP", }) - } catch (e) { + } catch { // ignore error - no ui is open return false } @@ -150,7 +150,7 @@ export default class BackgroundUIService implements IBackgroundUIService { async closePopup() { try { await this.sendMessageToClientUIService({ type: "CLOSE_POPUP" }) - } catch (e) { + } catch { // ignore error - no ui is open } } @@ -205,7 +205,18 @@ export default class BackgroundUIService implements IBackgroundUIService { } return } + await this.createFloatingWindow(initialRoute) + } + async openUiAsFloatingWindow() { + if (await this.uiService.hasFloatingWindow()) { + await this.uiService.focusFloatingWindow() + return + } + await this.createFloatingWindow() + } + + private async createFloatingWindow(initialRoute?: string) { let left = 0 let top = 0 try { @@ -216,7 +227,7 @@ export default class BackgroundUIService implements IBackgroundUIService { left = (lastFocused.left ?? 0) + Math.max((lastFocused.width ?? 0) - NOTIFICATION_WIDTH, 0) - } catch (_) { + } catch { // The following properties are more than likely 0, due to being // opened from the background chrome process for the extension that // has no physical dimensions @@ -226,7 +237,6 @@ export default class BackgroundUIService implements IBackgroundUIService { } const url = urlWithQuery("index.html", initialRoute ? { initialRoute } : {}) - await this.browser.windows.create({ url, type: "popup", @@ -236,7 +246,6 @@ export default class BackgroundUIService implements IBackgroundUIService { top, }) } - async showNotification(payload: UIShowNotificationPayload) { await this.sendMessageToClientUIService({ type: "SHOW_NOTIFICATION", diff --git a/packages/extension/src/background/services/ui/IBackgroundUIService.ts b/packages/extension/src/background/services/ui/IBackgroundUIService.ts index 7c83526b3..b7d75755a 100644 --- a/packages/extension/src/background/services/ui/IBackgroundUIService.ts +++ b/packages/extension/src/background/services/ui/IBackgroundUIService.ts @@ -3,7 +3,7 @@ * - 'popup' refers to the normal extension window opened by user clicking extension icon */ -import Emittery from "emittery" +import type Emittery from "emittery" import type { UIShowNotificationPayload } from "../../../shared/ui/UIMessage" export const Opened = Symbol("Opened") @@ -34,6 +34,11 @@ export interface IBackgroundUIService { */ openUi(initialRoute?: string): Promise + /** + * Opens ui as a floating window, regardles of whether it's already opened in a tab + */ + openUiAsFloatingWindow(): Promise + /** * Determine if there is an existing popup * @returns true if it exists diff --git a/packages/extension/src/background/services/worker/schedule/decorators.ts b/packages/extension/src/background/services/worker/schedule/decorators.ts index ed88fa1ab..8a583d36b 100644 --- a/packages/extension/src/background/services/worker/schedule/decorators.ts +++ b/packages/extension/src/background/services/worker/schedule/decorators.ts @@ -1,10 +1,11 @@ -import { IDebounceService } from "../../../../shared/debounce" -import { IScheduleService } from "../../../../shared/schedule/IScheduleService" +import type { IDebounceService } from "../../../../shared/debounce" +import type { IScheduleService } from "../../../../shared/schedule/IScheduleService" import { Locked } from "../../../wallet/session/interface" -import { WalletSessionService } from "../../../wallet/session/WalletSessionService" -import { IKeyValueStorage } from "../../../../shared/storage" -import { WalletStorageProps } from "../../../wallet/backup/WalletBackupService" -import { IBackgroundUIService, Opened } from "../../ui/IBackgroundUIService" +import type { WalletSessionService } from "../../../wallet/session/WalletSessionService" +import type { IKeyValueStorage } from "../../../../shared/storage" +import type { WalletStorageProps } from "../../../wallet/backup/WalletBackupService" +import type { IBackgroundUIService } from "../../ui/IBackgroundUIService" +import { Opened } from "../../ui/IBackgroundUIService" import { pipe } from "./pipe" type Fn = (...args: unknown[]) => Promise @@ -115,7 +116,9 @@ export const onOpenSmoothed = let timeout: ReturnType const delay = 300 + Math.random() * 1000 backgroundUIService.emitter.on(Opened, async (open) => { - timeout && clearTimeout(timeout) + if (timeout) { + clearTimeout(timeout) + } if (open) { timeout = setTimeout(() => { void fn() diff --git a/packages/extension/src/background/services/worker/schedule/mockBackgroundUIService.ts b/packages/extension/src/background/services/worker/schedule/mockBackgroundUIService.ts index e5ad03ea6..990573d17 100644 --- a/packages/extension/src/background/services/worker/schedule/mockBackgroundUIService.ts +++ b/packages/extension/src/background/services/worker/schedule/mockBackgroundUIService.ts @@ -1,6 +1,7 @@ import Emittery from "emittery" -import { MinimalIBackgroundUIService } from "./decorators" -import { Events, Opened } from "../../ui/IBackgroundUIService" +import type { MinimalIBackgroundUIService } from "./decorators" +import type { Events } from "../../ui/IBackgroundUIService" +import { Opened } from "../../ui/IBackgroundUIService" interface MockBackgroundUIServiceManager { setOpened(opened: boolean): Promise diff --git a/packages/extension/src/background/services/worker/schedule/mockSessionService.ts b/packages/extension/src/background/services/worker/schedule/mockSessionService.ts index 453986d65..6d9771df2 100644 --- a/packages/extension/src/background/services/worker/schedule/mockSessionService.ts +++ b/packages/extension/src/background/services/worker/schedule/mockSessionService.ts @@ -1,6 +1,7 @@ import Emittery from "emittery" -import { MinimalWalletSessionService } from "./decorators" -import { Events, Locked } from "../../../wallet/session/interface" +import type { MinimalWalletSessionService } from "./decorators" +import type { Events } from "../../../wallet/session/interface" +import { Locked } from "../../../wallet/session/interface" interface MockSssionServiceManager { setLocked(locked: boolean): Promise diff --git a/packages/extension/src/background/test/__fixtures__/activities.ts b/packages/extension/src/background/test/__fixtures__/activities.ts new file mode 100644 index 000000000..b6fb883d3 --- /dev/null +++ b/packages/extension/src/background/test/__fixtures__/activities.ts @@ -0,0 +1,266 @@ +import type { AnyActivity } from "@argent/x-shared/simulation" + +export const CHANGE_SIGNER_ACTIVITIES: AnyActivity[] = [ + { + actions: [ + { + defaultProperties: [ + { + address: + "0x02418f74a90c5f8488d011c811a6d40148ca3f3491965cf247fb03a85ba88213", + label: "default_contract", + type: "address", + verified: false, + }, + { + calldata: [ + "1765301336315676601532060479096852900977484130601559877201289629195991382459", + "3054856045889167199790395788568711774993534189789049011472882256484377131424", + ], + entrypoint: "replace_signer", + label: "default_call", + type: "calldata", + }, + ], + name: "account_multisig_replace_signer", + properties: [ + { + address: + "0x03e72009beaa727fcd904f75a75799b0158fb67269c4fe4ef16029edee49f9bb", + label: "account_multisig_replace_signer_removed_signer", + type: "address", + }, + { + address: + "0x06c0fcbc5948d472c752bd59f229d1e3431bd75a26b7f4532e507c666a5579a0", + label: "account_multisig_replace_signer_added_signer", + type: "address", + }, + ], + }, + ], + lastModified: 1725273484704, + meta: { + icon: "MultisigReplaceIcon", + title: "Replace signer", + }, + status: "success", + submitted: 1725273484000, + transaction: { + hash: "0x031b35c2bd1221f0d1b1be7081db7724b492591d70ca84e606c021641d4f3453", + }, + transferSummary: [], + type: "native", + }, +] + +export const SEND_ACTIVITIES: AnyActivity[] = [ + { + actions: [ + { + defaultProperties: [ + { + label: "default_contract", + token: { + address: + "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + decimals: 18, + iconUrl: "https://dv3jj1unlp2jl.cloudfront.net/128/color/eth.png", + name: "Ether", + symbol: "ETH", + type: "ERC20", + unknown: false, + }, + type: "token_address", + }, + { + calldata: [ + "838349379419027770435865540859909192769131100758756469463880374432643758894", + "100000000000000", + "0", + ], + entrypoint: "transfer", + label: "default_call", + type: "calldata", + }, + ], + name: "ERC20_transfer", + properties: [ + { + amount: "100000000000000", + editable: false, + label: "ERC20_transfer_amount", + token: { + address: + "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + decimals: 18, + iconUrl: "https://dv3jj1unlp2jl.cloudfront.net/128/color/eth.png", + name: "Ether", + symbol: "ETH", + type: "ERC20", + unknown: false, + }, + type: "amount", + usd: "0.25", + }, + { + address: + "0x01da7d2abee399ae7b4721c413a7521a46e748dda0e2368897980d13cc0d3b2e", + label: "ERC20_transfer_recipient", + type: "address", + verified: false, + }, + ], + }, + ], + fees: [ + { + actualFee: { + amount: "99443817198622", + fiatAmount: { + currency: "USD", + currencyAmount: 0.2509, + }, + tokenAddress: + "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + type: "token", + }, + to: "0x1176a1bd84444c89232ec27754698e5d2e7e1a7f1539f12027f28b23ec9f3d8", + type: "gas", + }, + ], + lastModified: 1725275485560, + meta: { + icon: "SendIcon", + subtitle: "To: 0x01dA...3b2e", + title: "Send", + }, + multisigDetails: { + signers: [ + "0x06c0fcbc5948d472c752bd59f229d1e3431bd75a26b7f4532e507c666a5579a0", + "0x0141611519ff946ec55650efff36a1d65c0b253ec39d7742fcf548985294eed0", + ], + }, + status: "success", + submitted: 1725275485000, + transaction: { + hash: "0x023e80b5b1ed6b4544042955827bdbf90051170baff24b987df5af9de550fee9", + }, + transferSummary: [ + { + asset: { + amount: "100000000000000", + tokenAddress: + "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + type: "token", + }, + sent: true, + }, + ], + type: "native", + }, + { + actions: [ + { + defaultProperties: [ + { + label: "default_contract", + token: { + address: + "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + decimals: 18, + iconUrl: "https://dv3jj1unlp2jl.cloudfront.net/128/color/eth.png", + name: "Ether", + symbol: "ETH", + type: "ERC20", + unknown: false, + }, + type: "token_address", + }, + { + calldata: [ + "838349379419027770435865540859909192769131100758756469463880374432643758894", + "10000000000000", + "0", + ], + entrypoint: "transfer", + label: "default_call", + type: "calldata", + }, + ], + name: "ERC20_transfer", + properties: [ + { + amount: "10000000000000", + editable: false, + label: "ERC20_transfer_amount", + token: { + address: + "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + decimals: 18, + iconUrl: "https://dv3jj1unlp2jl.cloudfront.net/128/color/eth.png", + name: "Ether", + symbol: "ETH", + type: "ERC20", + unknown: false, + }, + type: "amount", + usd: "0.03", + }, + { + address: + "0x01da7d2abee399ae7b4721c413a7521a46e748dda0e2368897980d13cc0d3b2e", + label: "ERC20_transfer_recipient", + type: "address", + verified: false, + }, + ], + }, + ], + fees: [ + { + actualFee: { + amount: "99381021054458", + fiatAmount: { + currency: "USD", + currencyAmount: 0.2508, + }, + tokenAddress: + "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + type: "token", + }, + to: "0x1176a1bd84444c89232ec27754698e5d2e7e1a7f1539f12027f28b23ec9f3d8", + type: "gas", + }, + ], + lastModified: 1725275404698, + meta: { + icon: "SendIcon", + subtitle: "To: 0x01dA...3b2e", + title: "Send", + }, + multisigDetails: { + signers: [ + "0x06c0fcbc5948d472c752bd59f229d1e3431bd75a26b7f4532e507c666a5579a0", + "0x0141611519ff946ec55650efff36a1d65c0b253ec39d7742fcf548985294eed0", + ], + }, + status: "success", + submitted: 1725275404000, + transaction: { + hash: "0x03c8112fbd069e4361c5068d3d9808adbf211ab65acc44cc3b0b327b00ac5ea3", + }, + transferSummary: [ + { + asset: { + amount: "10000000000000", + tokenAddress: + "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + type: "token", + }, + sent: true, + }, + ], + type: "native", + }, +] diff --git a/packages/extension/src/background/tokenMessaging.ts b/packages/extension/src/background/tokenMessaging.ts index 734dce7ef..e6f66a7c4 100644 --- a/packages/extension/src/background/tokenMessaging.ts +++ b/packages/extension/src/background/tokenMessaging.ts @@ -1,7 +1,8 @@ -import { TokenMessage } from "../shared/messages/TokenMessage" +import type { TokenMessage } from "../shared/messages/TokenMessage" import { defaultNetwork } from "../shared/network" import { tokenService } from "../shared/token/__new/service" -import { HandleMessage, UnhandledMessage } from "./background" +import type { HandleMessage } from "./background" +import { UnhandledMessage } from "./background" export const handleTokenMessaging: HandleMessage = async ({ msg, diff --git a/packages/extension/src/background/transactions/badgeText.ts b/packages/extension/src/background/transactions/badgeText.ts index 9e36d09f9..d8851c275 100644 --- a/packages/extension/src/background/transactions/badgeText.ts +++ b/packages/extension/src/background/transactions/badgeText.ts @@ -1,23 +1,20 @@ -import { memoize } from "lodash-es" - import { hideNotificationBadge, showNotificationBadge, } from "../../shared/browser/badgeText" -import { - MultisigPendingTransaction, - multisigPendingTransactionsStore, -} from "../../shared/multisig/pendingTransactionsStore" +import type { MultisigPendingTransaction } from "../../shared/multisig/pendingTransactionsStore" +import { multisigPendingTransactionsStore } from "../../shared/multisig/pendingTransactionsStore" import { getMultisigAccountFromBaseWallet } from "../../shared/multisig/utils/baseMultisig" -import { Transaction } from "../../shared/transactions" -import { +import type { Transaction } from "../../shared/transactions" +import type { BaseWalletAccount, - isNetworkOnlyPlaceholderAccount, MultisigWalletAccount, } from "../../shared/wallet.model" +import { isNetworkOnlyPlaceholderAccount } from "../../shared/wallet.model" import { accountsEqual } from "../../shared/utils/accountsEqual" import { old_walletStore } from "../../shared/wallet/walletStore" import { getTransactionStatus } from "../../shared/transactions/utils" +import memoize from "memoizee" // selects transactions that are pending and match the provided account @@ -30,6 +27,7 @@ export const pendingAccountTransactionsSelector = memoize( accountsEqual(account, transaction.account) ) }, + { normalizer: ([acc]) => acc.id }, ) export const multisigPendingTransactionSelector = memoize( @@ -37,6 +35,7 @@ export const multisigPendingTransactionSelector = memoize( (transaction: MultisigPendingTransaction) => { return accountsEqual(multisig, transaction.account) && transaction.notify }, + { normalizer: ([acc]) => acc.id }, ) // show count of pending transactions for current account @@ -78,12 +77,12 @@ export const updateBadgeText = async () => { export const initBadgeText = () => { old_walletStore.subscribe("selected", () => { - updateBadgeText() + void updateBadgeText() }) multisigPendingTransactionsStore.subscribe(() => { - updateBadgeText() + void updateBadgeText() }) - updateBadgeText() + void updateBadgeText() } diff --git a/packages/extension/src/background/transactions/determineUpdates.ts b/packages/extension/src/background/transactions/determineUpdates.ts index 493d056bd..d4d605637 100644 --- a/packages/extension/src/background/transactions/determineUpdates.ts +++ b/packages/extension/src/background/transactions/determineUpdates.ts @@ -1,4 +1,5 @@ -import { Transaction, compareTransactions } from "../../shared/transactions" +import type { Transaction } from "../../shared/transactions" +import { compareTransactions } from "../../shared/transactions" import { getTransactionStatus } from "../../shared/transactions/utils" export function getTransactionsStatusUpdate( diff --git a/packages/extension/src/background/transactions/onupdate/changeGuardian.ts b/packages/extension/src/background/transactions/onupdate/changeGuardian.ts index a0063f618..ada19588a 100644 --- a/packages/extension/src/background/transactions/onupdate/changeGuardian.ts +++ b/packages/extension/src/background/transactions/onupdate/changeGuardian.ts @@ -1,5 +1,5 @@ import { updateAccountDetails } from "../../../shared/account/update" -import { TransactionUpdateListener } from "./type" +import type { TransactionUpdateListener } from "./type" export const handleChangeGuardianTransaction: TransactionUpdateListener = async (transactions) => { diff --git a/packages/extension/src/background/transactions/onupdate/declareContract.ts b/packages/extension/src/background/transactions/onupdate/declareContract.ts index 2f5e7ad16..956efb08f 100644 --- a/packages/extension/src/background/transactions/onupdate/declareContract.ts +++ b/packages/extension/src/background/transactions/onupdate/declareContract.ts @@ -1,6 +1,6 @@ import { declaredTransactionsStore } from "../../../shared/udc/store" import { UdcTransactionType } from "../../udcAction" -import { TransactionUpdateListener } from "./type" +import type { TransactionUpdateListener } from "./type" import { getTransactionStatus } from "../../../shared/transactions/utils" export const handleDeclareContractTransaction: TransactionUpdateListener = diff --git a/packages/extension/src/background/transactions/onupdate/deployAccount.ts b/packages/extension/src/background/transactions/onupdate/deployAccount.ts index 9058bc482..1f2ed83a8 100644 --- a/packages/extension/src/background/transactions/onupdate/deployAccount.ts +++ b/packages/extension/src/background/transactions/onupdate/deployAccount.ts @@ -1,6 +1,6 @@ import { updateAccountDetails } from "../../../shared/account/update" import { SUCCESS_STATUSES } from "../../../shared/transactions" -import { TransactionUpdateListener } from "./type" +import type { TransactionUpdateListener } from "./type" import { getTransactionStatus } from "../../../shared/transactions/utils" export const handleDeployAccountTransaction: TransactionUpdateListener = async ( diff --git a/packages/extension/src/background/transactions/onupdate/index.ts b/packages/extension/src/background/transactions/onupdate/index.ts index 564846e95..ac197ba95 100644 --- a/packages/extension/src/background/transactions/onupdate/index.ts +++ b/packages/extension/src/background/transactions/onupdate/index.ts @@ -2,8 +2,7 @@ import { handleChangeGuardianTransaction } from "./changeGuardian" import { handleDeclareContractTransaction } from "./declareContract" import { handleDeployAccountTransaction } from "./deployAccount" import { handleMultisigUpdates } from "./multisigUpdates" -import { checkResetStoredNonce } from "./nonce" -import { TransactionUpdateListener } from "./type" +import type { TransactionUpdateListener } from "./type" import { handleUpgradeTransaction } from "./upgrade" const addedOrUpdatedHandlers: TransactionUpdateListener[] = [ @@ -12,7 +11,6 @@ const addedOrUpdatedHandlers: TransactionUpdateListener[] = [ handleDeclareContractTransaction, handleChangeGuardianTransaction, handleMultisigUpdates, - checkResetStoredNonce, ] export const runAddedOrUpdatedHandlers: TransactionUpdateListener = async ( diff --git a/packages/extension/src/background/transactions/onupdate/multisigUpdates.ts b/packages/extension/src/background/transactions/onupdate/multisigUpdates.ts index 554ad1333..adc6b4fb5 100644 --- a/packages/extension/src/background/transactions/onupdate/multisigUpdates.ts +++ b/packages/extension/src/background/transactions/onupdate/multisigUpdates.ts @@ -1,6 +1,7 @@ import { updateMultisigAccountDetails } from "../../../shared/account/update" -import { MULTISG_TXN_TYPES, Transaction } from "../../../shared/transactions" -import { TransactionUpdateListener } from "./type" +import type { Transaction } from "../../../shared/transactions" +import { MULTISG_TXN_TYPES } from "../../../shared/transactions" +import type { TransactionUpdateListener } from "./type" export const handleMultisigUpdates: TransactionUpdateListener = async ( updates: Transaction[], diff --git a/packages/extension/src/background/transactions/onupdate/nonce.ts b/packages/extension/src/background/transactions/onupdate/nonce.ts deleted file mode 100644 index 47fae79c0..000000000 --- a/packages/extension/src/background/transactions/onupdate/nonce.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { resetStoredNonce } from "../../nonce" -import { TransactionUpdateListener } from "./type" -import { getTransactionStatus } from "../../../shared/transactions/utils" - -export const checkResetStoredNonce: TransactionUpdateListener = async ( - transactions, -) => { - for (const transaction of transactions) { - // on error remove stored (increased) nonce - const { finality_status } = getTransactionStatus(transaction) - if (transaction.account && finality_status === "REJECTED") { - await resetStoredNonce(transaction.account) - } - } -} diff --git a/packages/extension/src/background/transactions/onupdate/upgrade.ts b/packages/extension/src/background/transactions/onupdate/upgrade.ts index a4b2795d6..038076827 100644 --- a/packages/extension/src/background/transactions/onupdate/upgrade.ts +++ b/packages/extension/src/background/transactions/onupdate/upgrade.ts @@ -2,12 +2,12 @@ import { optimisticImplUpdate } from "../../../shared/account/optimisticImplUpda import { accountService } from "../../../shared/account/service" import { transformTransaction } from "../../../shared/activity/utils/transform" import { isUpgradeTransaction } from "../../../shared/activity/utils/transform/is" -import { Transaction } from "../../../shared/transactions" +import type { Transaction } from "../../../shared/transactions" import { isSuccessfulTransaction } from "../../../shared/transactions/utils" import { accountsEqual } from "../../../shared/utils/accountsEqual" import { isSafeUpgradeTransaction } from "../../../shared/utils/isSafeUpgradeTransaction" -import { WalletAccount } from "../../../shared/wallet.model" -import { TransactionUpdateListener } from "./type" +import type { WalletAccount } from "../../../shared/wallet.model" +import type { TransactionUpdateListener } from "./type" export const handleUpgradeTransaction: TransactionUpdateListener = async ( transactions, diff --git a/packages/extension/src/background/transactions/sources/onchain.spec.ts b/packages/extension/src/background/transactions/sources/onchain.spec.ts index 5f7204aa9..a178e7dcf 100644 --- a/packages/extension/src/background/transactions/sources/onchain.spec.ts +++ b/packages/extension/src/background/transactions/sources/onchain.spec.ts @@ -1,9 +1,9 @@ import { describe, expect, test, vi } from "vitest" -import { Hex } from "@argent/x-shared" +import type { Hex } from "@argent/x-shared" import { getTransactionsUpdate } from "./onchain" -import { WalletAccount } from "../../../shared/wallet.model" -import { ExtendedFinalityStatus } from "../../../shared/transactions" +import type { WalletAccount } from "../../../shared/wallet.model" +import type { ExtendedFinalityStatus } from "../../../shared/transactions" const mocks = vi.hoisted(() => { return { diff --git a/packages/extension/src/background/transactions/sources/onchain.ts b/packages/extension/src/background/transactions/sources/onchain.ts index b5f89f48d..58f0fa730 100644 --- a/packages/extension/src/background/transactions/sources/onchain.ts +++ b/packages/extension/src/background/transactions/sources/onchain.ts @@ -1,7 +1,7 @@ import { getProvider } from "../../../shared/network" +import type { Transaction } from "../../../shared/transactions" import { SUCCESS_STATUSES, - Transaction, getInFlightTransactions, } from "../../../shared/transactions" import { getTransactionStatus } from "../../../shared/transactions/utils" diff --git a/packages/extension/src/background/transactions/sources/voyager.model.ts b/packages/extension/src/background/transactions/sources/voyager.model.ts index 29b138155..56bff3477 100644 --- a/packages/extension/src/background/transactions/sources/voyager.model.ts +++ b/packages/extension/src/background/transactions/sources/voyager.model.ts @@ -1,4 +1,7 @@ -import { TransactionExecutionStatus, TransactionFinalityStatus } from "starknet" +import type { + TransactionExecutionStatus, + TransactionFinalityStatus, +} from "starknet" export interface VoyagerTransaction { blockId: string diff --git a/packages/extension/src/background/transactions/sources/voyager.ts b/packages/extension/src/background/transactions/sources/voyager.ts index bca3c10b4..4a957a9cd 100644 --- a/packages/extension/src/background/transactions/sources/voyager.ts +++ b/packages/extension/src/background/transactions/sources/voyager.ts @@ -1,13 +1,14 @@ import { ARGENT_EXPLORER_BASE_URL } from "../../../shared/api/constants" import { argentApiNetworkForNetwork } from "../../../shared/api/headers" -import { Network } from "../../../shared/network" -import { Transaction, compareTransactions } from "../../../shared/transactions" +import type { Network } from "../../../shared/network" +import type { Transaction } from "../../../shared/transactions" +import { compareTransactions } from "../../../shared/transactions" import { urlWithQuery } from "../../../shared/utils/url" -import { WalletAccount } from "../../../shared/wallet.model" +import type { WalletAccount } from "../../../shared/wallet.model" import { stripAddressZeroPadding } from "@argent/x-shared" import { fetchWithTimeout } from "../../utils/fetchWithTimeout" import { mapVoyagerTransactionToTransaction } from "../transformers" -import { VoyagerTransaction } from "./voyager.model" +import type { VoyagerTransaction } from "./voyager.model" export const fetchVoyagerTransactions = async ( address: string, diff --git a/packages/extension/src/background/transactions/transactionAdapter.ts b/packages/extension/src/background/transactions/transactionAdapter.ts index c9816a4d5..18477afa0 100644 --- a/packages/extension/src/background/transactions/transactionAdapter.ts +++ b/packages/extension/src/background/transactions/transactionAdapter.ts @@ -1,5 +1,6 @@ -import { CallData, Call } from "starknet" -import { Call as Callv4 } from "starknet4" +import type { Call } from "starknet" +import { CallData } from "starknet" +import type { Call as Callv4 } from "starknet4" export function transactionCallsAdapter(transactions: Call | Call[]): Callv4[] { const calls = Array.isArray(transactions) ? transactions : [transactions] diff --git a/packages/extension/src/background/transactions/transactionExecution.ts b/packages/extension/src/background/transactions/transactionExecution.ts index eb3ada799..24032d2bc 100644 --- a/packages/extension/src/background/transactions/transactionExecution.ts +++ b/packages/extension/src/background/transactions/transactionExecution.ts @@ -1,9 +1,14 @@ import { + ensureArray, estimatedFeeToMaxResourceBounds, + ETH_TOKEN_ADDRESS, getTxVersionFromFeeToken, + isEqualAddress, + STRK_TOKEN_ADDRESS, } from "@argent/x-shared" +import type { AllowArray, Call } from "starknet" import { TransactionType, num } from "starknet" -import { +import type { ExtQueueItem, TransactionActionPayload, } from "../../shared/actionQueue/types" @@ -12,11 +17,11 @@ import { SessionError } from "../../shared/errors/session" import { TransactionError } from "../../shared/errors/transaction" import { getMultisigAccountFromBaseWallet } from "../../shared/multisig/utils/baseMultisig" import { getEstimatedFees } from "../../shared/transactionSimulation/fees/estimatedFeesRepository" -import { +import type { ExtendedFinalityStatus, TransactionRequest, - nameTransaction, } from "../../shared/transactions" +import { nameTransaction } from "../../shared/transactions" import { addTransaction, transactionsStore, @@ -28,8 +33,10 @@ import { import { accountsEqual } from "../../shared/utils/accountsEqual" import { isSafeUpgradeTransaction } from "../../shared/utils/isSafeUpgradeTransaction" import { isAccountDeployed } from "../accountDeploy" -import { getNonce, increaseStoredNonce } from "../nonce" -import { Wallet } from "../wallet" +import type { Wallet } from "../wallet" +import { isArgentAccount } from "../../shared/utils/isExternalAccount" +import { nonceManagementService } from "../nonceManagement" +import { addTransactionHash } from "../../shared/transactions/transactionHashes/transactionHashesRepository" export type TransactionAction = ExtQueueItem<{ type: "TRANSACTION" @@ -40,7 +47,7 @@ export const executeTransactionAction = async ( action: TransactionAction, wallet: Wallet, ) => { - const { transactions, abis, transactionsDetail, meta = {} } = action.payload + const { transactions, transactionsDetail, meta = {} } = action.payload const allTransactions = await transactionsStore.get() const preComputedFees = await getEstimatedFees({ type: TransactionType.INVOKE, @@ -78,7 +85,7 @@ export const executeTransactionAction = async ( ) const starknetAccount = await wallet.getStarknetAccount( - selectedAccount, + selectedAccount.id, hasUpgradePending, ) @@ -89,21 +96,36 @@ export const executeTransactionAction = async ( // if nonce doesnt get provided by the UI, we can use the stored nonce to allow transaction queueing const nonceWasProvidedByUI = transactionsDetail?.nonce !== undefined // nonce can be a number of 0 therefore we need to check for undefined + const nonce = accountNeedsDeploy ? num.toHex(1) : nonceWasProvidedByUI ? num.toHex(transactionsDetail?.nonce || 0) - : await getNonce(selectedAccount, starknetAccount) + : await nonceManagementService.getNonce(selectedAccount.id) const version = getTxVersionFromFeeToken( preComputedFees.transactions.feeTokenAddress, ) - if (accountNeedsDeploy && preComputedFees.deployment) { - const { account, txHash } = await wallet.deployAccount(selectedAccount, { + if ( + isArgentAccount(selectedAccount) && + accountNeedsDeploy && + preComputedFees.deployment + ) { + const deployDetails = { version, ...estimatedFeeToMaxResourceBounds(preComputedFees.deployment), - }) + } + + const deployTxHash = await wallet.getDeployAccountTransactionHash( + selectedAccount, + deployDetails, + ) + + const { account, txHash } = await wallet.deployAccount( + selectedAccount, + deployDetails, + ) if (!checkTransactionHash(txHash)) { throw Error( "Deploy Account Transaction could not get added to the sequencer", @@ -134,12 +156,23 @@ export const executeTransactionAction = async ( throw new Error("Old Accounts are not supported anymore") } - const transaction = await acc.execute(transactions, abis, { - ...transactionsDetail, + const txDetails = { + ...(transactionsDetail || {}), + ...estimatedFeeToMaxResourceBounds(preComputedFees.transactions), nonce, version, - ...estimatedFeeToMaxResourceBounds(preComputedFees.transactions), - }) + } + + if (!isStrkOrEthTransfer(transactions)) { + const calculatedTxHash = await acc.getInvokeTransactionHash( + transactions, + txDetails, + ) + + await addTransactionHash(action.meta.hash, calculatedTxHash) + } + + const transaction = await acc.execute(transactions, txDetails) if (!checkTransactionHash(transaction.transaction_hash, selectedAccount)) { throw new Error("Transaction could not get added to the sequencer") @@ -166,8 +199,22 @@ export const executeTransactionAction = async ( // This will not execute for multisig transactions if (!nonceWasProvidedByUI && finalityStatus === "RECEIVED") { - await increaseStoredNonce(selectedAccount) + await nonceManagementService.increaseLocalNonce(selectedAccount.id) } return transaction } + +const isStrkOrEthTransfer = (calls: AllowArray) => { + const callsArray = ensureArray(calls) + if (callsArray.length === 0) { + return false + } + const call = callsArray[0] + + return ( + call.entrypoint === "transfer" && + (isEqualAddress(call.contractAddress, ETH_TOKEN_ADDRESS) || + isEqualAddress(call.contractAddress, STRK_TOKEN_ADDRESS)) + ) +} diff --git a/packages/extension/src/background/transactions/transactionMessaging.ts b/packages/extension/src/background/transactions/transactionMessaging.ts index 2dad32a1e..956c955d5 100644 --- a/packages/extension/src/background/transactions/transactionMessaging.ts +++ b/packages/extension/src/background/transactions/transactionMessaging.ts @@ -1,40 +1,11 @@ -import { - CallData, - Invocations, - TransactionType, - num, - transaction, -} from "starknet" - -import { TransactionMessage } from "../../shared/messages/TransactionMessage" -import { - SimulateDeployAccountRequest, - SimulateInvokeRequest, -} from "../../shared/transactionSimulation/types" -import { isAccountDeployed } from "../accountDeploy" -import { HandleMessage, UnhandledMessage } from "../background" -import { AccountError } from "../../shared/errors/account" -import { fetchTransactionBulkSimulation } from "../../shared/transactionSimulation/transactionSimulation.service" -import { TransactionError } from "../../shared/errors/transaction" -import { - isAccountV4, - getTxVersionFromFeeToken, - getSimulationTxVersionFromFeeToken, - getTxVersionFromFeeTokenForDeclareContract, - getEstimatedFeeFromBulkSimulation, -} from "@argent/x-shared" -import { EstimatedFees } from "@argent/x-shared/simulation" -import { addEstimatedFee } from "../../shared/transactionSimulation/fees/estimatedFeesRepository" +import type { TransactionMessage } from "../../shared/messages/TransactionMessage" import { DAPP_TRANSACTION_TITLE } from "../../shared/transactions/utils" +import type { HandleMessage } from "../background" +import { UnhandledMessage } from "../background" export const handleTransactionMessage: HandleMessage< TransactionMessage -> = async ({ - msg, - origin, - background: { wallet, actionService, feeTokenService }, - respond, -}) => { +> = async ({ msg, origin, background: { actionService }, respond }) => { switch (msg.type) { case "EXECUTE_TRANSACTION": { const { meta } = await actionService.add( @@ -45,7 +16,7 @@ export const handleTransactionMessage: HandleMessage< { origin, title: DAPP_TRANSACTION_TITLE, - icon: "NetworkIcon", + icon: "NetworkSecondaryIcon", }, ) return respond({ @@ -54,491 +25,6 @@ export const handleTransactionMessage: HandleMessage< }) } - case "ESTIMATE_DECLARE_CONTRACT_FEE": { - const { account, feeTokenAddress, payload } = msg.data - - const selectedAccount = await wallet.getSelectedAccount() - const selectedStarknetAccount = account - ? await wallet.getStarknetAccount(account) - : await wallet.getSelectedStarknetAccount() - - if (!selectedStarknetAccount) { - throw Error("no accounts") - } - - const fees: EstimatedFees = { - transactions: { - feeTokenAddress, - amount: 0n, - pricePerUnit: 0n, - dataGasConsumed: 0n, - dataGasPrice: 0n, - }, - } - - try { - const version = getTxVersionFromFeeTokenForDeclareContract( - feeTokenAddress, - payload, - ) - - if ( - selectedAccount?.needsDeploy && - !(await isAccountDeployed( - selectedAccount, - selectedStarknetAccount.getClassAt.bind(selectedStarknetAccount), - )) - ) { - if ("estimateFeeBulk" in selectedStarknetAccount) { - const deployPayload = - selectedAccount.type === "multisig" - ? await wallet.getMultisigDeploymentPayload(selectedAccount) - : await wallet.getAccountDeploymentPayload(selectedAccount) - const bulkTransactions: Invocations = [ - { - type: TransactionType.DEPLOY_ACCOUNT, - payload: deployPayload, - }, - { - type: TransactionType.DECLARE, - payload, - }, - ] - - const estimateFeeBulk = - await selectedStarknetAccount.estimateFeeBulk(bulkTransactions, { - skipValidate: true, - version, - }) - - if ( - !estimateFeeBulk[0].gas_consumed || - !estimateFeeBulk[0].gas_price - ) { - throw Error( - "estimateFeeBulk[0].gas_consumed or estimateFeeBulk[0].gas_price is undefined", - ) - } - - fees.deployment = { - feeTokenAddress, - amount: num.toBigInt(estimateFeeBulk[0].gas_consumed), - pricePerUnit: num.toBigInt(estimateFeeBulk[0].gas_price), - dataGasConsumed: num.toBigInt( - estimateFeeBulk[0].data_gas_consumed, - ), - dataGasPrice: num.toBigInt(estimateFeeBulk[0].data_gas_price), - } - - if ( - !estimateFeeBulk[1].gas_consumed || - !estimateFeeBulk[1].gas_price - ) { - throw Error( - "estimateFeeBulk[1].gas_consumed or estimateFeeBulk[1].gas_price is undefined", - ) - } - - const { - gas_consumed, - gas_price, - data_gas_consumed, - data_gas_price, - } = estimateFeeBulk[1] - - fees.transactions = { - feeTokenAddress, - amount: num.toBigInt(gas_consumed), - pricePerUnit: num.toBigInt(gas_price), - dataGasConsumed: num.toBigInt(data_gas_consumed), - dataGasPrice: num.toBigInt(data_gas_price), - } - } - } else { - if ("estimateDeclareFee" in selectedStarknetAccount) { - const { - gas_consumed, - gas_price, - data_gas_consumed, - data_gas_price, - } = await selectedStarknetAccount.estimateDeclareFee(payload, { - version, - }) - - if (!gas_consumed || !gas_price) { - throw Error("gas_consumed or gas_price is undefined") - } - - fees.transactions = { - feeTokenAddress, - amount: num.toBigInt(gas_consumed), - pricePerUnit: num.toBigInt(gas_price), - dataGasConsumed: num.toBigInt(data_gas_consumed), - dataGasPrice: num.toBigInt(data_gas_price), - } - } else { - throw Error("estimateDeclareFee not supported") - } - } - - await addEstimatedFee(fees, { - type: TransactionType.DECLARE, - payload, - }) - - return respond({ - type: "ESTIMATE_DECLARE_CONTRACT_FEE_RES", - data: fees, - }) - } catch (error) { - console.error(error) - return respond({ - type: "ESTIMATE_DECLARE_CONTRACT_FEE_REJ", - data: { - error: - (error as any)?.message?.toString?.() ?? - (error as any)?.toString?.() ?? - "Unkown error", - }, - }) - } - } - - case "ESTIMATE_DEPLOY_CONTRACT_FEE": { - const { payload, account, feeTokenAddress } = msg.data - - const selectedAccount = await wallet.getSelectedAccount() - const selectedStarknetAccount = account - ? await wallet.getStarknetAccount(account) - : await wallet.getSelectedStarknetAccount() - - if (!selectedStarknetAccount || !selectedAccount) { - throw Error("no accounts") - } - - const fees: EstimatedFees = { - transactions: { - feeTokenAddress, - amount: 0n, - pricePerUnit: 0n, - dataGasConsumed: 0n, - dataGasPrice: 0n, - }, - } - - const version = getTxVersionFromFeeToken(feeTokenAddress) - - try { - if ( - selectedAccount?.needsDeploy && - !(await isAccountDeployed( - selectedAccount, - selectedStarknetAccount.getClassAt.bind(selectedStarknetAccount), - )) - ) { - if ("estimateFeeBulk" in selectedStarknetAccount) { - const bulkTransactions: Invocations = [ - { - type: TransactionType.DEPLOY_ACCOUNT, - payload: - await wallet.getAccountDeploymentPayload(selectedAccount), - }, - { - type: TransactionType.DEPLOY, - payload, - }, - ] - - const estimateFeeBulk = - await selectedStarknetAccount.estimateFeeBulk(bulkTransactions) - - if ( - !estimateFeeBulk[0].gas_consumed || - !estimateFeeBulk[0].gas_price - ) { - throw Error( - "estimateFeeBulk[0].gas_consumed or estimateFeeBulk[0].gas_price is undefined", - ) - } - - fees.deployment = { - feeTokenAddress, - amount: num.toBigInt(estimateFeeBulk[0].gas_consumed), - pricePerUnit: num.toBigInt(estimateFeeBulk[0].gas_price), - dataGasConsumed: num.toBigInt( - estimateFeeBulk[0].data_gas_consumed, - ), - dataGasPrice: num.toBigInt(estimateFeeBulk[0].data_gas_price), - } - - if ( - !estimateFeeBulk[1].gas_consumed || - !estimateFeeBulk[1].gas_price - ) { - throw Error( - "estimateFeeBulk[1].gas_consumed or estimateFeeBulk[1].gas_price is undefined", - ) - } - - fees.transactions = { - feeTokenAddress, - amount: num.toBigInt(estimateFeeBulk[1].gas_consumed), - pricePerUnit: num.toBigInt(estimateFeeBulk[1].gas_price), - dataGasConsumed: num.toBigInt( - estimateFeeBulk[1].data_gas_consumed, - ), - dataGasPrice: num.toBigInt(estimateFeeBulk[1].data_gas_price), - } - } - } else { - if ("estimateDeployFee" in selectedStarknetAccount) { - const { - gas_consumed, - gas_price, - data_gas_consumed, - data_gas_price, - } = await selectedStarknetAccount.estimateDeployFee(payload, { - version, - }) - - if (!gas_consumed || !gas_price) { - throw Error("gas_consumed or gas_price is undefined") - } - - fees.transactions = { - feeTokenAddress, - amount: num.toBigInt(gas_consumed), - pricePerUnit: num.toBigInt(gas_price), - dataGasConsumed: num.toBigInt(data_gas_consumed), - dataGasPrice: num.toBigInt(data_gas_price), - } - } else { - throw Error("estimateDeployFee not supported") - } - } - - await addEstimatedFee(fees, { - type: TransactionType.DEPLOY, - payload, - }) - - return respond({ - type: "ESTIMATE_DEPLOY_CONTRACT_FEE_RES", - data: fees, - }) - } catch (error) { - console.log(error) - return respond({ - type: "ESTIMATE_DEPLOY_CONTRACT_FEE_REJ", - data: { - error: - (error as any)?.message?.toString() ?? - (error as any)?.toString() ?? - "Unkown error", - }, - }) - } - } - - case "SIMULATE_TRANSACTION_INVOCATION": { - const transactions = Array.isArray(msg.data) ? msg.data : [msg.data] - - try { - const selectedAccount = await wallet.getSelectedAccount() - if (!selectedAccount) { - throw new AccountError({ code: "NOT_FOUND" }) - } - const starknetAccount = await wallet.getSelectedStarknetAccount() - - if (!("transactionVersion" in starknetAccount)) { - // Old accounts are not supported - return respond({ - type: "SIMULATE_TRANSACTION_INVOCATION_RES", - data: null, - }) - } - - const nonce = await starknetAccount.getNonce().catch(() => "0") - - const chainId = await starknetAccount.getChainId() - - const bestFeeToken = - await feeTokenService.getBestFeeToken(selectedAccount) - const version = getSimulationTxVersionFromFeeToken(bestFeeToken.address) - - const calldata = transaction.getExecuteCalldata( - transactions, - starknetAccount.cairoVersion, - ) - - let accountDeployTransaction: SimulateDeployAccountRequest | null = null - - const isDeployed = await isAccountDeployed( - selectedAccount, - starknetAccount.getClassAt.bind(starknetAccount), - ) - - const invokeTransactions: SimulateInvokeRequest = { - type: TransactionType.INVOKE, - sender_address: selectedAccount.address, - calldata, - signature: [], - nonce: isDeployed ? num.toHex(nonce) : num.toHex(1), - version, - } - - if (!isDeployed) { - const accountDeployPayload = - await wallet.getAccountDeploymentPayload(selectedAccount) - - accountDeployTransaction = { - type: TransactionType.DEPLOY_ACCOUNT, - calldata: CallData.toCalldata( - accountDeployPayload.constructorCalldata, - ), - classHash: num.toHex(accountDeployPayload.classHash), - salt: num.toHex(accountDeployPayload.addressSalt || 0), - nonce: num.toHex(0), - version: num.toHex(version), - signature: [], - } - } - - return respond({ - type: "SIMULATE_TRANSACTION_INVOCATION_RES", - data: { - transactions: accountDeployTransaction - ? [accountDeployTransaction, invokeTransactions] - : [invokeTransactions], - chainId, - }, - }) - } catch (error) { - console.error("SIMULATE_TRANSACTION_INVOCATION_REJ", error) - return respond({ - type: "SIMULATE_TRANSACTION_INVOCATION_REJ", - data: { - error: - (error as any)?.message?.toString() ?? - (error as any)?.toString() ?? - "Unkown error", - }, - }) - } - } - - case "SIMULATE_TRANSACTIONS": { - const transactions = Array.isArray(msg.data.call) - ? msg.data.call - : [msg.data.call] - - try { - const selectedAccount = await wallet.getSelectedAccount() - if (!selectedAccount) { - throw new AccountError({ code: "NOT_FOUND" }) - } - const starknetAccount = await wallet.getSelectedStarknetAccount() - if (isAccountV4(starknetAccount)) { - // Old accounts are not supported - // This should no longer happen as we prevent deprecated accounts from being used - return respond({ - type: "SIMULATE_TRANSACTIONS_REJ", - data: { - error: new TransactionError({ - code: "DEPRECATED_ACCOUNT", - }), - }, - }) - } - - const nonce = await starknetAccount.getNonce().catch(() => "0") - - const chainId = await starknetAccount.getChainId() - - const version = getSimulationTxVersionFromFeeToken( - msg.data.feeTokenAddress, - ) - const calldata = transaction.getExecuteCalldata( - transactions, - starknetAccount.cairoVersion, - ) - - let accountDeployTransaction: SimulateDeployAccountRequest | null = null - - const isDeployed = await isAccountDeployed( - selectedAccount, - starknetAccount.getClassAt.bind(starknetAccount), - ) - - const invokeTransactions: SimulateInvokeRequest = { - type: TransactionType.INVOKE, - sender_address: selectedAccount.address, - calldata, - signature: [], - nonce: isDeployed ? num.toHex(nonce) : num.toHex(1), - version, - } - - if (!isDeployed) { - const accountDeployPayload = - await wallet.getAccountDeploymentPayload(selectedAccount) - - accountDeployTransaction = { - type: TransactionType.DEPLOY_ACCOUNT, - calldata: CallData.toCalldata( - accountDeployPayload.constructorCalldata, - ), - classHash: num.toHex(accountDeployPayload.classHash), - salt: num.toHex(accountDeployPayload.addressSalt || 0), - nonce: num.toHex(0), - version: num.toHex(version), - signature: [], - } - } - - const invocations = accountDeployTransaction - ? [accountDeployTransaction, invokeTransactions] - : [invokeTransactions] - - const result = await fetchTransactionBulkSimulation({ - invocations, - networkId: selectedAccount.networkId, - chainId, - }) - - const estimatedFee = getEstimatedFeeFromBulkSimulation(result) - - let simulationWithFees = null - - if (result) { - await addEstimatedFee(estimatedFee, { - type: TransactionType.INVOKE, - payload: transactions, - }) - simulationWithFees = { - simulation: result, - feeEstimation: estimatedFee, - } - } - - return respond({ - type: "SIMULATE_TRANSACTIONS_RES", - data: simulationWithFees, - }) - } catch (error) { - console.error("SIMULATE_TRANSACTIONS_REJ", error, "kek") - return respond({ - type: "SIMULATE_TRANSACTIONS_REJ", - data: { - error: new TransactionError({ - code: "SIMULATION_ERROR", - message: `${error}`, - }), - }, - }) - } - } - case "TRANSACTION_FAILED": { return await actionService.remove(msg.data.actionHash) } diff --git a/packages/extension/src/background/transactions/transformers.ts b/packages/extension/src/background/transactions/transformers.ts index 1d76f6074..029926d98 100644 --- a/packages/extension/src/background/transactions/transformers.ts +++ b/packages/extension/src/background/transactions/transformers.ts @@ -1,6 +1,6 @@ -import { Transaction } from "../../shared/transactions" -import { WalletAccount } from "../../shared/wallet.model" -import { VoyagerTransaction } from "./sources/voyager.model" +import type { Transaction } from "../../shared/transactions" +import type { WalletAccount } from "../../shared/wallet.model" +import type { VoyagerTransaction } from "./sources/voyager.model" export const mapVoyagerTransactionToTransaction = ( transaction: VoyagerTransaction, diff --git a/packages/extension/src/background/trpc/procedures/account/select.ts b/packages/extension/src/background/trpc/procedures/account/select.ts index b9587707f..6034a8d3b 100644 --- a/packages/extension/src/background/trpc/procedures/account/select.ts +++ b/packages/extension/src/background/trpc/procedures/account/select.ts @@ -4,7 +4,7 @@ import { getOtherTabsOrigins, } from "../../../../shared/browser/origin" import { - baseWalletAccountSchema, + accountIdSchema, isNetworkOnlyPlaceholderAccount, networkOnlyPlaceholderAccountSchema, } from "../../../../shared/wallet.model" @@ -15,7 +15,7 @@ import { extensionOnlyProcedure } from "../permissions" export const selectAccountProcedure = extensionOnlyProcedure .use(openSessionMiddleware) - .input(baseWalletAccountSchema.or(networkOnlyPlaceholderAccountSchema)) + .input(accountIdSchema.or(networkOnlyPlaceholderAccountSchema)) .mutation( async ({ input: baseWalletAccount, diff --git a/packages/extension/src/background/trpc/procedures/account/upgrade.ts b/packages/extension/src/background/trpc/procedures/account/upgrade.ts index e423bc432..67e50e68f 100644 --- a/packages/extension/src/background/trpc/procedures/account/upgrade.ts +++ b/packages/extension/src/background/trpc/procedures/account/upgrade.ts @@ -1,6 +1,7 @@ import { z } from "zod" import { + accountIdSchema, argentAccountTypeSchema, walletAccountSchema, } from "../../../../shared/wallet.model" @@ -10,9 +11,14 @@ import { extensionOnlyProcedure } from "../permissions" import { getAccountClassHashFromChain } from "../../../../shared/account/details" import { networkService } from "../../../../shared/network/service" import { isEqualAddress } from "@argent/x-shared" +import { + isArgentAccount, + isImportedArgentAccount, +} from "../../../../shared/utils/isExternalAccount" +import { AccountError } from "../../../../shared/errors/account" const upgradeAccountSchema = z.object({ - account: walletAccountSchema, + accountId: accountIdSchema, targetImplementationType: argentAccountTypeSchema.optional(), }) @@ -22,12 +28,32 @@ export const upgradeAccountProcedure = extensionOnlyProcedure .output(z.tuple([z.boolean(), walletAccountSchema])) .mutation( async ({ - input: { account, targetImplementationType }, + input: { accountId, targetImplementationType }, ctx: { services: { wallet, actionService }, }, }) => { - const [onchainAccount] = await getAccountClassHashFromChain([account]) + const account = await wallet.getAccount(accountId) + + if (!account) { + throw new Error("Account not found") + } + + const onchainAccount = isArgentAccount(account) + ? (await getAccountClassHashFromChain([account]))[0] + : isImportedArgentAccount(account) // Support upgrade for imported argent accounts + ? { + id: account.id, + address: account.address, + networkId: account.network.id, + classHash: account.classHash, + type: account.type, + } + : null + + if (!onchainAccount) { + throw new AccountError({ code: "IMPORTED_UPGRADE_NOT_SUPPORTED" }) + } const { accountClassHash } = await networkService.getById( account.network.id, @@ -37,8 +63,12 @@ export const upgradeAccountProcedure = extensionOnlyProcedure throw new Error("Account class hash not found") } - const targetClassHash = - accountClassHash[targetImplementationType ?? account.type] + const accountClassHashType = + targetImplementationType || isImportedArgentAccount(account) + ? "standard" + : account.type + + const targetClassHash = accountClassHash[accountClassHashType] if ( onchainAccount.classHash && diff --git a/packages/extension/src/background/trpc/procedures/accountMessaging/cancelEscape.ts b/packages/extension/src/background/trpc/procedures/accountMessaging/cancelEscape.ts index 90e8e49b2..1b37e8e2e 100644 --- a/packages/extension/src/background/trpc/procedures/accountMessaging/cancelEscape.ts +++ b/packages/extension/src/background/trpc/procedures/accountMessaging/cancelEscape.ts @@ -50,7 +50,7 @@ export const cancelEscapeProcedure = extensionOnlyProcedure }, { title: "Keep guardian", - icon: "SmartAccountActiveIcon", + icon: "ShieldSecondaryIcon", }, ) } catch (error) { diff --git a/packages/extension/src/background/trpc/procedures/accountMessaging/changeGuardian.ts b/packages/extension/src/background/trpc/procedures/accountMessaging/changeGuardian.ts index c2f8313ff..b670da4d6 100644 --- a/packages/extension/src/background/trpc/procedures/accountMessaging/changeGuardian.ts +++ b/packages/extension/src/background/trpc/procedures/accountMessaging/changeGuardian.ts @@ -68,8 +68,8 @@ export const changeGuardianProcedure = extensionOnlyProcedure title: isRemoveGuardian ? "Remove Guardian" : "Add Guardian", shortTitle: "Change guardian", icon: isRemoveGuardian - ? "SmartAccountInactiveIcon" - : "SmartAccountActiveIcon", + ? "NoShieldSecondaryIcon" + : "ShieldSecondaryIcon", subtitle: "", }, ) diff --git a/packages/extension/src/background/trpc/procedures/accountMessaging/escapeAndChangeGuardian.ts b/packages/extension/src/background/trpc/procedures/accountMessaging/escapeAndChangeGuardian.ts index 322843fab..c19426c49 100644 --- a/packages/extension/src/background/trpc/procedures/accountMessaging/escapeAndChangeGuardian.ts +++ b/packages/extension/src/background/trpc/procedures/accountMessaging/escapeAndChangeGuardian.ts @@ -31,7 +31,7 @@ export const escapeAndChangeGuardianProcedure = extensionOnlyProcedure * 2. changeGuardian to ZERO, signed twice by same signer key (like 2/2 multisig with same key) */ - const selectedAccount = await wallet.getAccount(account) + const selectedAccount = await wallet.getAccount(account.id) const starknetAccount = await wallet.getSelectedStarknetAccount() if (!selectedAccount) { @@ -40,7 +40,7 @@ export const escapeAndChangeGuardianProcedure = extensionOnlyProcedure }) } - const { publicKey } = await wallet.getPublicKey(account) + const { publicKey } = await wallet.getPublicKey(account.id) if ( selectedAccount.guardian && @@ -69,7 +69,7 @@ export const escapeAndChangeGuardianProcedure = extensionOnlyProcedure isChangeGuardian: true, title: "Remove guardian (1/2)", type: "INVOKE", - icon: "SmartAccountInactiveIcon", + icon: "NoShieldSecondaryIcon", ampliProperties: { "is deployment": false, "transaction type": "remove guardian", @@ -109,7 +109,7 @@ export const escapeAndChangeGuardianProcedure = extensionOnlyProcedure isChangeGuardian: true, title: "Remove guardian (2/2)", type: "INVOKE", - icon: "SmartAccountInactiveIcon", + icon: "NoShieldSecondaryIcon", ampliProperties: { "is deployment": false, "transaction type": "remove guardian", diff --git a/packages/extension/src/background/trpc/procedures/accountMessaging/getAccountDeploymentPayload.ts b/packages/extension/src/background/trpc/procedures/accountMessaging/getAccountDeploymentPayload.ts index 94283794f..f4146df61 100644 --- a/packages/extension/src/background/trpc/procedures/accountMessaging/getAccountDeploymentPayload.ts +++ b/packages/extension/src/background/trpc/procedures/accountMessaging/getAccountDeploymentPayload.ts @@ -13,6 +13,7 @@ import { bigNumberishSchema, rawArgsSchema, } from "@argent/x-shared" +import { walletAccountToArgentAccount } from "../../../../shared/utils/isExternalAccount" const getAccountDeploymentPayloadInputSchema = z .object({ @@ -48,7 +49,7 @@ export const getAccountDeploymentPayloadProcedure = connectedDappsProcedure } try { const walletAccount = input?.account - ? await wallet.getAccount(input.account) + ? await wallet.getAccount(input.account.id) : await wallet.getSelectedAccount() if (!walletAccount) { throw new AccountError({ @@ -60,7 +61,9 @@ export const getAccountDeploymentPayloadProcedure = connectedDappsProcedure return null } - return await wallet.getAccountDeploymentPayload(walletAccount) + return await wallet.getAccountOrMultisigDeploymentPayload( + walletAccountToArgentAccount(walletAccount), + ) } catch (e) { throw new AccountMessagingError({ options: { error: e }, diff --git a/packages/extension/src/background/trpc/procedures/accountMessaging/getEncryptedPrivateKey.ts b/packages/extension/src/background/trpc/procedures/accountMessaging/getEncryptedPrivateKey.ts index 9e10ab0e9..7b8303db4 100644 --- a/packages/extension/src/background/trpc/procedures/accountMessaging/getEncryptedPrivateKey.ts +++ b/packages/extension/src/background/trpc/procedures/accountMessaging/getEncryptedPrivateKey.ts @@ -1,14 +1,13 @@ import { z } from "zod" import { extensionOnlyProcedure } from "../permissions" -import { baseWalletAccountSchema } from "../../../../shared/wallet.model" import { encryptForUi } from "../../../crypto" import { AccountMessagingError } from "../../../../shared/errors/accountMessaging" import { SessionError } from "../../../../shared/errors/session" const getEncryptedPrivateKeySchema = z.object({ encryptedSecret: z.string(), - account: baseWalletAccountSchema, + accountId: z.string(), }) export const getEncryptedPrivateKeyProcedure = extensionOnlyProcedure @@ -16,7 +15,7 @@ export const getEncryptedPrivateKeyProcedure = extensionOnlyProcedure .output(z.string()) .mutation( async ({ - input: { account, encryptedSecret }, + input: { accountId, encryptedSecret }, ctx: { services: { wallet, messagingKeys }, }, @@ -28,7 +27,7 @@ export const getEncryptedPrivateKeyProcedure = extensionOnlyProcedure } try { return await encryptForUi( - await wallet.getPrivateKey(account), + await wallet.getPrivateKey(accountId), encryptedSecret, messagingKeys.privateKey, ) diff --git a/packages/extension/src/background/trpc/procedures/accountMessaging/getNextPublicKey.ts b/packages/extension/src/background/trpc/procedures/accountMessaging/getNextPublicKey.ts index 892f8a1e6..289f1b47e 100644 --- a/packages/extension/src/background/trpc/procedures/accountMessaging/getNextPublicKey.ts +++ b/packages/extension/src/background/trpc/procedures/accountMessaging/getNextPublicKey.ts @@ -1,10 +1,10 @@ import { z } from "zod" -import { extensionOnlyProcedure } from "../permissions" import { createAccountTypeSchema, signerTypeSchema, } from "../../../../shared/wallet.model" +import { extensionOnlyProcedure } from "../permissions" const getNextPublicKeyForMultisigSchema = z.object({ networkId: z.string(), @@ -12,9 +12,15 @@ const getNextPublicKeyForMultisigSchema = z.object({ accountType: createAccountTypeSchema, }) +const getNextPublicKeyForMultisigOutputSchema = z.object({ + publicKey: z.string(), + index: z.number(), + derivationPath: z.string(), +}) + export const getNextPublicKeyProcedure = extensionOnlyProcedure .input(getNextPublicKeyForMultisigSchema) - .output(z.string()) + .output(getNextPublicKeyForMultisigOutputSchema) .mutation( async ({ input: { accountType, signerType, networkId }, @@ -22,11 +28,11 @@ export const getNextPublicKeyProcedure = extensionOnlyProcedure services: { wallet }, }, }) => { - const { publicKey } = await wallet.getNextPublicKey( + const result = await wallet.getNextPublicKey( accountType, signerType, networkId, ) - return publicKey + return result }, ) diff --git a/packages/extension/src/background/trpc/procedures/accountMessaging/getPublicKey.ts b/packages/extension/src/background/trpc/procedures/accountMessaging/getPublicKey.ts index 2d0d94cae..878f07bee 100644 --- a/packages/extension/src/background/trpc/procedures/accountMessaging/getPublicKey.ts +++ b/packages/extension/src/background/trpc/procedures/accountMessaging/getPublicKey.ts @@ -1,11 +1,10 @@ import { z } from "zod" import { extensionOnlyProcedure } from "../permissions" -import { baseWalletAccountSchema } from "../../../../shared/wallet.model" import { AccountMessagingError } from "../../../../shared/errors/accountMessaging" const getPublicKeySchema = z.object({ - account: z.optional(baseWalletAccountSchema), + accountId: z.string().optional(), }) export const getPublicKeyProcedure = extensionOnlyProcedure @@ -13,13 +12,13 @@ export const getPublicKeyProcedure = extensionOnlyProcedure .output(z.string()) .query( async ({ - input: { account }, + input: { accountId }, ctx: { services: { wallet }, }, }) => { try { - const { publicKey } = await wallet.getPublicKey(account) + const { publicKey } = await wallet.getPublicKey(accountId) return publicKey } catch (error) { throw new AccountMessagingError({ diff --git a/packages/extension/src/background/trpc/procedures/accountMessaging/getPublicKeysBufferForMultisig.ts b/packages/extension/src/background/trpc/procedures/accountMessaging/getPublicKeysBufferForMultisig.ts index 2f6da5b9c..00dfc6498 100644 --- a/packages/extension/src/background/trpc/procedures/accountMessaging/getPublicKeysBufferForMultisig.ts +++ b/packages/extension/src/background/trpc/procedures/accountMessaging/getPublicKeysBufferForMultisig.ts @@ -24,7 +24,7 @@ export const getPublicKeysBufferForMultisigProcedure = extensionOnlyProcedure buffer, ) return pubKeys - } catch (error) { + } catch { throw new PubKeyError({ code: "FAILED_BUFFER_GENERATION", }) diff --git a/packages/extension/src/background/trpc/procedures/dappMessaging/connectDapp.ts b/packages/extension/src/background/trpc/procedures/dappMessaging/connectDapp.ts index f797756bf..776737ae2 100644 --- a/packages/extension/src/background/trpc/procedures/dappMessaging/connectDapp.ts +++ b/packages/extension/src/background/trpc/procedures/dappMessaging/connectDapp.ts @@ -3,7 +3,7 @@ import type { IUIService } from "../../../../shared/ui/IUIService" import type { IBackgroundActionService } from "../../../services/action/IBackgroundActionService" import { Opened } from "../../../services/ui" import type { IBackgroundUIService } from "../../../services/ui/IBackgroundUIService" -import { Wallet } from "../../../wallet" +import type { Wallet } from "../../../wallet" import type { ConnectDappInput } from "./schema" export const connectDapp = async ({ diff --git a/packages/extension/src/background/trpc/procedures/importAccount/import.ts b/packages/extension/src/background/trpc/procedures/importAccount/import.ts new file mode 100644 index 000000000..e5b287ea6 --- /dev/null +++ b/packages/extension/src/background/trpc/procedures/importAccount/import.ts @@ -0,0 +1,17 @@ +import { extensionOnlyProcedure } from "../permissions" +import { validatedImportSchema } from "../../../../shared/accountImport/types" +import { walletAccountSchema } from "../../../../shared/wallet.model" + +export const importProcedure = extensionOnlyProcedure + .input(validatedImportSchema) + .output(walletAccountSchema) + .mutation( + async ({ + input: validatedAccount, + ctx: { + services: { wallet }, + }, + }) => { + return await wallet.importAccount(validatedAccount) + }, + ) diff --git a/packages/extension/src/background/trpc/procedures/importAccount/index.ts b/packages/extension/src/background/trpc/procedures/importAccount/index.ts new file mode 100644 index 000000000..c01379b67 --- /dev/null +++ b/packages/extension/src/background/trpc/procedures/importAccount/index.ts @@ -0,0 +1,8 @@ +import { router } from "../../trpc" +import { importProcedure } from "./import" +import { validateProcedure } from "./validate" + +export const importAccountRouter = router({ + validate: validateProcedure, + import: importProcedure, +}) diff --git a/packages/extension/src/background/trpc/procedures/importAccount/validate.ts b/packages/extension/src/background/trpc/procedures/importAccount/validate.ts new file mode 100644 index 000000000..bc7f6cb3c --- /dev/null +++ b/packages/extension/src/background/trpc/procedures/importAccount/validate.ts @@ -0,0 +1,24 @@ +import { z } from "zod" +import { extensionOnlyProcedure } from "../permissions" +import { importValidationResult } from "../../../../shared/accountImport/types" +import { addressSchema, hexSchema } from "@argent/x-shared" + +export const validateImportAccountSchema = z.object({ + address: addressSchema, + pk: hexSchema, + networkId: z.string(), +}) + +export const validateProcedure = extensionOnlyProcedure + .input(validateImportAccountSchema) + .output(importValidationResult) + .query( + async ({ + input: { address, pk, networkId }, + ctx: { + services: { importAccountService }, + }, + }) => { + return await importAccountService.validateImport(address, pk, networkId) + }, + ) diff --git a/packages/extension/src/background/trpc/procedures/investments/getAllInvestments.ts b/packages/extension/src/background/trpc/procedures/investments/getAllInvestments.ts new file mode 100644 index 000000000..f2b0373a6 --- /dev/null +++ b/packages/extension/src/background/trpc/procedures/investments/getAllInvestments.ts @@ -0,0 +1,11 @@ +import { extensionOnlyProcedure } from "../permissions" + +export const getAllInvestmentsProcedure = extensionOnlyProcedure.query( + async ({ + ctx: { + services: { investmentService }, + }, + }) => { + return await investmentService.getAllInvestments() + }, +) diff --git a/packages/extension/src/background/trpc/procedures/investments/getStrkDelegatedStakingInvestments.ts b/packages/extension/src/background/trpc/procedures/investments/getStrkDelegatedStakingInvestments.ts new file mode 100644 index 000000000..dcef00e6a --- /dev/null +++ b/packages/extension/src/background/trpc/procedures/investments/getStrkDelegatedStakingInvestments.ts @@ -0,0 +1,12 @@ +import { extensionOnlyProcedure } from "../permissions" + +export const getStrkDelegatedStakingInvestmentsProcedure = + extensionOnlyProcedure.query( + async ({ + ctx: { + services: { investmentService }, + }, + }) => { + return await investmentService.getStrkDelegatedStakingInvestments() + }, + ) diff --git a/packages/extension/src/background/trpc/procedures/investments/index.ts b/packages/extension/src/background/trpc/procedures/investments/index.ts new file mode 100644 index 000000000..ec55260da --- /dev/null +++ b/packages/extension/src/background/trpc/procedures/investments/index.ts @@ -0,0 +1,9 @@ +import { router } from "../../trpc" +import { getAllInvestmentsProcedure } from "./getAllInvestments" +import { getStrkDelegatedStakingInvestmentsProcedure } from "./getStrkDelegatedStakingInvestments" + +export const investmentsRouter = router({ + getAllInvestments: getAllInvestmentsProcedure, + getStrkDelegatedStakingInvestments: + getStrkDelegatedStakingInvestmentsProcedure, +}) diff --git a/packages/extension/src/background/trpc/procedures/staking/claim.ts b/packages/extension/src/background/trpc/procedures/staking/claim.ts new file mode 100644 index 000000000..9094702b9 --- /dev/null +++ b/packages/extension/src/background/trpc/procedures/staking/claim.ts @@ -0,0 +1,15 @@ +import { extensionOnlyProcedure } from "../permissions" +import { strkStakingCalldataWithAccountTypeSchema } from "../../../../shared/staking/types" + +export const claimProcedure = extensionOnlyProcedure + .input(strkStakingCalldataWithAccountTypeSchema) + .mutation( + async ({ + input, + ctx: { + services: { stakingService }, + }, + }) => { + return await stakingService.claim(input) + }, + ) diff --git a/packages/extension/src/background/trpc/procedures/staking/index.ts b/packages/extension/src/background/trpc/procedures/staking/index.ts new file mode 100644 index 000000000..d4b0f38fe --- /dev/null +++ b/packages/extension/src/background/trpc/procedures/staking/index.ts @@ -0,0 +1,14 @@ +import { router } from "../../trpc" +import { claimProcedure } from "./claim" +import { initiateUnstakeProcedure } from "./initiateUnstake" +import { stakeProcedure } from "./stake" +import { unstakeProcedure } from "./unstake" +import { stakeCalldataProcedure } from "./stakeCalldata" + +export const stakingRouter = router({ + stake: stakeProcedure, + stakeCalldata: stakeCalldataProcedure, + claim: claimProcedure, + initiateUnstake: initiateUnstakeProcedure, + unstake: unstakeProcedure, +}) diff --git a/packages/extension/src/background/trpc/procedures/staking/initiateUnstake.ts b/packages/extension/src/background/trpc/procedures/staking/initiateUnstake.ts new file mode 100644 index 000000000..1bbf60328 --- /dev/null +++ b/packages/extension/src/background/trpc/procedures/staking/initiateUnstake.ts @@ -0,0 +1,15 @@ +import { extensionOnlyProcedure } from "../permissions" +import { strkStakingCalldataWithAccountTypeSchema } from "../../../../shared/staking/types" + +export const initiateUnstakeProcedure = extensionOnlyProcedure + .input(strkStakingCalldataWithAccountTypeSchema) + .mutation( + async ({ + input, + ctx: { + services: { stakingService }, + }, + }) => { + return await stakingService.initiateUnstake(input) + }, + ) diff --git a/packages/extension/src/background/trpc/procedures/staking/stake.ts b/packages/extension/src/background/trpc/procedures/staking/stake.ts new file mode 100644 index 000000000..d747a8e03 --- /dev/null +++ b/packages/extension/src/background/trpc/procedures/staking/stake.ts @@ -0,0 +1,15 @@ +import { extensionOnlyProcedure } from "../permissions" +import { strkStakingCalldataWithAccountTypeSchema } from "../../../../shared/staking/types" + +export const stakeProcedure = extensionOnlyProcedure + .input(strkStakingCalldataWithAccountTypeSchema) + .mutation( + async ({ + input, + ctx: { + services: { stakingService }, + }, + }) => { + return await stakingService.stake(input) + }, + ) diff --git a/packages/extension/src/background/trpc/procedures/staking/stakeCalldata.ts b/packages/extension/src/background/trpc/procedures/staking/stakeCalldata.ts new file mode 100644 index 000000000..f7a2ce3f2 --- /dev/null +++ b/packages/extension/src/background/trpc/procedures/staking/stakeCalldata.ts @@ -0,0 +1,15 @@ +import { extensionOnlyProcedure } from "../permissions" +import { strkStakingCalldataSchema } from "@argent/x-shared" + +export const stakeCalldataProcedure = extensionOnlyProcedure + .input(strkStakingCalldataSchema) + .query( + async ({ + input, + ctx: { + services: { stakingService }, + }, + }) => { + return await stakingService.getStakeCalldata(input) + }, + ) diff --git a/packages/extension/src/background/trpc/procedures/staking/unstake.ts b/packages/extension/src/background/trpc/procedures/staking/unstake.ts new file mode 100644 index 000000000..0cf8cdcf0 --- /dev/null +++ b/packages/extension/src/background/trpc/procedures/staking/unstake.ts @@ -0,0 +1,15 @@ +import { extensionOnlyProcedure } from "../permissions" +import { strkStakingCalldataWithAccountTypeSchema } from "../../../../shared/staking/types" + +export const unstakeProcedure = extensionOnlyProcedure + .input(strkStakingCalldataWithAccountTypeSchema) + .mutation( + async ({ + input, + ctx: { + services: { stakingService }, + }, + }) => { + return await stakingService.unstake(input) + }, + ) diff --git a/packages/extension/src/background/trpc/procedures/swap/getSwapQuoteForPay.ts b/packages/extension/src/background/trpc/procedures/swap/getSwapQuoteForPay.ts index 59834014e..0691d0e91 100644 --- a/packages/extension/src/background/trpc/procedures/swap/getSwapQuoteForPay.ts +++ b/packages/extension/src/background/trpc/procedures/swap/getSwapQuoteForPay.ts @@ -5,7 +5,8 @@ import { SwapQuoteResponseSchema } from "../../../../shared/swap/model/quote.mod const SwapQuoteForPaySchema = z.object({ payTokenAddress: z.string(), receiveTokenAddress: z.string(), - payAmount: z.string(), + sellAmount: z.string().optional(), + buyAmount: z.string().optional(), accountAddress: z.string(), }) @@ -17,7 +18,8 @@ export const getSwapQuoteForPayProcedure = extensionOnlyProcedure input: { payTokenAddress, receiveTokenAddress, - payAmount, + sellAmount, + buyAmount, accountAddress, }, ctx: { @@ -27,8 +29,9 @@ export const getSwapQuoteForPayProcedure = extensionOnlyProcedure return swapService.getSwapQuoteForPay( payTokenAddress, receiveTokenAddress, - payAmount, accountAddress, + sellAmount, + buyAmount, ) }, ) diff --git a/packages/extension/src/background/trpc/procedures/swap/makeSwap.ts b/packages/extension/src/background/trpc/procedures/swap/makeSwap.ts index c7c0b8949..9c3393bc0 100644 --- a/packages/extension/src/background/trpc/procedures/swap/makeSwap.ts +++ b/packages/extension/src/background/trpc/procedures/swap/makeSwap.ts @@ -41,7 +41,7 @@ export const makeSwapProcedure = extensionOnlyProcedure { title, shortTitle: "Swap", - icon: "SwapIcon", + icon: "SwapPrimaryIcon", }, ) diff --git a/packages/extension/src/background/trpc/procedures/tokens/fetchAccountBalance.ts b/packages/extension/src/background/trpc/procedures/tokens/fetchAccountBalance.ts index 2fd997de8..65d32af8f 100644 --- a/packages/extension/src/background/trpc/procedures/tokens/fetchAccountBalance.ts +++ b/packages/extension/src/background/trpc/procedures/tokens/fetchAccountBalance.ts @@ -4,19 +4,18 @@ import { extensionOnlyProcedure } from "../permissions" import { tokenService } from "../../../../shared/token/__new/service" import { equalToken } from "../../../../shared/token/__new/utils" import { TokenError } from "../../../../shared/errors/token" +import { baseWalletAccountSchema } from "../../../../shared/wallet.model" const fetchTokenBalanceSchema = z.object({ tokenAddress: addressSchema, - accountAddress: addressSchema, - networkId: z.string(), + account: baseWalletAccountSchema, }) export const fetchTokenBalanceProcedure = extensionOnlyProcedure .input(fetchTokenBalanceSchema) .output(z.string()) - .mutation(async ({ input: { tokenAddress, accountAddress, networkId } }) => { - const account = { address: accountAddress, networkId } - const baseToken = { address: tokenAddress, networkId } + .mutation(async ({ input: { tokenAddress, account } }) => { + const baseToken = { address: tokenAddress, networkId: account.networkId } const [token] = await tokenService.getTokens((t) => equalToken(t, baseToken), ) diff --git a/packages/extension/src/background/trpc/procedures/tokens/fetchDetails.ts b/packages/extension/src/background/trpc/procedures/tokens/fetchDetails.ts index b040893a2..9f408c724 100644 --- a/packages/extension/src/background/trpc/procedures/tokens/fetchDetails.ts +++ b/packages/extension/src/background/trpc/procedures/tokens/fetchDetails.ts @@ -9,5 +9,5 @@ export const fetchDetailsProcedure = extensionOnlyProcedure .input(BaseTokenSchema) .output(RequestTokenSchema) .query(async ({ input: baseToken }) => { - return await tokenService.fetchTokenDetails(baseToken) + return tokenService.fetchTokenDetails(baseToken) }) diff --git a/packages/extension/src/background/trpc/procedures/tokens/fetchTokenActivities.ts b/packages/extension/src/background/trpc/procedures/tokens/fetchTokenActivities.ts new file mode 100644 index 000000000..4dad6ef2a --- /dev/null +++ b/packages/extension/src/background/trpc/procedures/tokens/fetchTokenActivities.ts @@ -0,0 +1,22 @@ +import { extensionOnlyProcedure } from "../permissions" + +import { z } from "zod" +import { addressSchema } from "@argent/x-shared" + +import { activityService } from "../../../services/activity" +import { baseWalletAccountSchema } from "../../../../shared/wallet.model" + +const fetchTokenActivitiesSchema = z.object({ + account: baseWalletAccountSchema, + tokenAddress: addressSchema, +}) +export const fetchTokenActivitiesProcedure = extensionOnlyProcedure + .input(fetchTokenActivitiesSchema) + .query(async ({ input }) => { + return await activityService.fetchAccountActivities( + input.account, + false, + input.tokenAddress, + true, + ) + }) diff --git a/packages/extension/src/background/trpc/procedures/tokens/fetchTokenGraph.ts b/packages/extension/src/background/trpc/procedures/tokens/fetchTokenGraph.ts new file mode 100644 index 000000000..371643212 --- /dev/null +++ b/packages/extension/src/background/trpc/procedures/tokens/fetchTokenGraph.ts @@ -0,0 +1,19 @@ +import { extensionOnlyProcedure } from "../permissions" + +import { apiTokenGraphDataSchema } from "../../../../shared/tokenDetails/interface" +import { z } from "zod" +import { addressSchema } from "@argent/x-shared" +import { tokenDetailsService } from "../../../services/tokenDetails" + +const fetchTokenGraphSchema = z.object({ + tokenAddress: addressSchema, + currency: z.string(), + timeFrame: z.string(), + chain: z.string(), +}) +export const fetchTokenGraphProcedure = extensionOnlyProcedure + .input(fetchTokenGraphSchema) + .output(apiTokenGraphDataSchema.optional()) + .query(async ({ input }) => { + return await tokenDetailsService.fetchTokenGraph(input) + }) diff --git a/packages/extension/src/background/trpc/procedures/tokens/getAccountBalance.ts b/packages/extension/src/background/trpc/procedures/tokens/getAccountBalance.ts index 88e04d654..18a607be8 100644 --- a/packages/extension/src/background/trpc/procedures/tokens/getAccountBalance.ts +++ b/packages/extension/src/background/trpc/procedures/tokens/getAccountBalance.ts @@ -26,7 +26,7 @@ export const getAccountBalanceProcedure = extensionOnlyProcedure }) } const account = { address: accountAddress, networkId } - const [tokenBalance] = await tokenService.getTokenBalancesForAccount( + const [tokenBalance] = await tokenService.getAllTokenBalancesForAccount( account, [token], ) diff --git a/packages/extension/src/background/trpc/procedures/tokens/getAllTokenBalances.ts b/packages/extension/src/background/trpc/procedures/tokens/getAllTokenBalances.ts index fd5d1db18..cbf2958af 100644 --- a/packages/extension/src/background/trpc/procedures/tokens/getAllTokenBalances.ts +++ b/packages/extension/src/background/trpc/procedures/tokens/getAllTokenBalances.ts @@ -22,7 +22,7 @@ export const getAllTokenBalancesProcedure = extensionOnlyProcedure ), ) const account = { address: accountAddress, networkId } - const tokenBalances = await tokenService.getTokenBalancesForAccount( + const tokenBalances = await tokenService.getAllTokenBalancesForAccount( account, tokens, ) diff --git a/packages/extension/src/background/trpc/procedures/tokens/getTokenBalance.ts b/packages/extension/src/background/trpc/procedures/tokens/getTokenBalance.ts new file mode 100644 index 000000000..3e625a796 --- /dev/null +++ b/packages/extension/src/background/trpc/procedures/tokens/getTokenBalance.ts @@ -0,0 +1,22 @@ +import { addressSchema } from "@argent/x-shared" + +import { z } from "zod" +import { extensionOnlyProcedure } from "../permissions" +import { tokenService } from "../../../../shared/token/__new/service" +import { equalToken } from "../../../../shared/token/__new/utils" + +const inputSchema = z.object({ + tokenAddress: addressSchema, + accountAddress: addressSchema, + networkId: z.string(), +}) + +export const getTokenBalanceProcedure = extensionOnlyProcedure + .input(inputSchema) + .query(async ({ input: { tokenAddress, accountAddress, networkId } }) => { + const [token] = await tokenService.getTokens((t) => + equalToken(t, { address: tokenAddress, networkId }), + ) + const account = { address: accountAddress, networkId } + return tokenService.getTokenBalanceForAccount(account, token) + }) diff --git a/packages/extension/src/background/trpc/procedures/tokens/hideTokenProcedure.ts b/packages/extension/src/background/trpc/procedures/tokens/hideTokenProcedure.ts new file mode 100644 index 000000000..a543d9d18 --- /dev/null +++ b/packages/extension/src/background/trpc/procedures/tokens/hideTokenProcedure.ts @@ -0,0 +1,15 @@ +import { extensionOnlyProcedure } from "../permissions" +import { BaseTokenSchema } from "../../../../shared/token/__new/types/token.model" +import { tokenService } from "../../../../shared/token/__new/service" +import { z } from "zod" + +const inputSchema = z.object({ + token: BaseTokenSchema, + hidden: z.boolean(), +}) + +export const toggleHideTokenProcedure = extensionOnlyProcedure + .input(inputSchema) + .mutation(async ({ input }) => { + return await tokenService.toggleHideToken(input.token, input.hidden) + }) diff --git a/packages/extension/src/background/trpc/procedures/tokens/index.ts b/packages/extension/src/background/trpc/procedures/tokens/index.ts index ce9a1e8fc..326875341 100644 --- a/packages/extension/src/background/trpc/procedures/tokens/index.ts +++ b/packages/extension/src/background/trpc/procedures/tokens/index.ts @@ -5,17 +5,27 @@ import { fetchCurrencyBalanceForAccountsFromBackendProcedure } from "./fetchToke import { fetchDetailsProcedure } from "./fetchDetails" import { getAccountBalanceProcedure } from "./getAccountBalance" import { getAllTokenBalancesProcedure } from "./getAllTokenBalances" +import { getTokenBalanceProcedure } from "./getTokenBalance" import { getCurrencyValueForTokensProcedure } from "./getCurrencyValueForTokens" import { removeTokenProcedure } from "./removeToken" +import { fetchTokenGraphProcedure } from "./fetchTokenGraph" +import { toggleHideTokenProcedure } from "./hideTokenProcedure" +import { fetchTokenActivitiesProcedure } from "./fetchTokenActivities" +import { reportSpamTokenProcedure } from "./reportSpamToken" export const tokensRouter = router({ addToken: addTokenProcedure, removeToken: removeTokenProcedure, + toggleHideToken: toggleHideTokenProcedure, + reportSpamToken: reportSpamTokenProcedure, fetchDetails: fetchDetailsProcedure, fetchTokenBalance: fetchTokenBalanceProcedure, getAccountBalance: getAccountBalanceProcedure, getAllTokenBalances: getAllTokenBalancesProcedure, + getTokenBalance: getTokenBalanceProcedure, getCurrencyValueForTokens: getCurrencyValueForTokensProcedure, fetchCurrencyBalanceForAccountsFromBackend: fetchCurrencyBalanceForAccountsFromBackendProcedure, + fetchTokenGraph: fetchTokenGraphProcedure, + fetchTokenActivities: fetchTokenActivitiesProcedure, }) diff --git a/packages/extension/src/background/trpc/procedures/tokens/reportSpamToken.ts b/packages/extension/src/background/trpc/procedures/tokens/reportSpamToken.ts new file mode 100644 index 000000000..2e5ebb536 --- /dev/null +++ b/packages/extension/src/background/trpc/procedures/tokens/reportSpamToken.ts @@ -0,0 +1,16 @@ +import { extensionOnlyProcedure } from "../permissions" +import { BaseTokenSchema } from "../../../../shared/token/__new/types/token.model" +import { tokenService } from "../../../../shared/token/__new/service" +import { z } from "zod" +import { baseWalletAccountSchema } from "../../../../shared/wallet.model" + +const inputSchema = z.object({ + token: BaseTokenSchema, + account: baseWalletAccountSchema, +}) + +export const reportSpamTokenProcedure = extensionOnlyProcedure + .input(inputSchema) + .mutation(async ({ input }) => { + return await tokenService.reportSpamToken(input.token, input.account) + }) diff --git a/packages/extension/src/background/trpc/procedures/transactionEstimate/accountDeploy.ts b/packages/extension/src/background/trpc/procedures/transactionEstimate/accountDeploy.ts index 195644dbc..db49248ee 100644 --- a/packages/extension/src/background/trpc/procedures/transactionEstimate/accountDeploy.ts +++ b/packages/extension/src/background/trpc/procedures/transactionEstimate/accountDeploy.ts @@ -7,6 +7,7 @@ import { addressSchema } from "@argent/x-shared" import { estimatedFeeSchema } from "@argent/x-shared/simulation" import { AccountError } from "../../../../shared/errors/account" import { getErrorObject } from "../../../../shared/utils/error" +import { walletAccountToArgentAccount } from "../../../../shared/utils/isExternalAccount" const estimateRequestSchema = z.object({ account: baseWalletAccountSchema.optional(), @@ -25,7 +26,7 @@ export const estimateAccountDeployProcedure = extensionOnlyProcedure }, }) => { const account = providedAccount - ? await wallet.getAccount(providedAccount) + ? await wallet.getAccount(providedAccount.id) : await wallet.getSelectedAccount() if (!account) { @@ -33,7 +34,10 @@ export const estimateAccountDeployProcedure = extensionOnlyProcedure } try { - return await wallet.getAccountDeploymentFee(account, feeTokenAddress) + return await wallet.getAccountDeploymentFee( + walletAccountToArgentAccount(account), + feeTokenAddress, + ) } catch (error) { console.error("estimateAccountDeployProcedure", error) throw new AccountError({ diff --git a/packages/extension/src/background/trpc/procedures/transactionEstimate/estimate.ts b/packages/extension/src/background/trpc/procedures/transactionEstimate/estimate.ts index ea968a3d6..d6c2a7826 100644 --- a/packages/extension/src/background/trpc/procedures/transactionEstimate/estimate.ts +++ b/packages/extension/src/background/trpc/procedures/transactionEstimate/estimate.ts @@ -34,12 +34,12 @@ export const estimateTransactionProcedure = extensionOnlyProcedure services: { wallet }, }, }) => { - const walletAccount = await wallet.getAccount(account) + const walletAccount = await wallet.getAccount(account.id) if (!walletAccount) { throw new AccountError({ code: "NOT_FOUND" }) } - const snAccount = await wallet.getStarknetAccount(account) + const snAccount = await wallet.getStarknetAccount(account.id) if (!("estimateFeeBulk" in snAccount)) { throw new AccountError({ code: "MISSING_METHOD" }) diff --git a/packages/extension/src/background/trpc/procedures/transactionEstimate/helpers.ts b/packages/extension/src/background/trpc/procedures/transactionEstimate/helpers.ts index ef34164a6..960d30f1c 100644 --- a/packages/extension/src/background/trpc/procedures/transactionEstimate/helpers.ts +++ b/packages/extension/src/background/trpc/procedures/transactionEstimate/helpers.ts @@ -1,15 +1,16 @@ -import { +import type { Call, EstimateFeeBulk, Invocations, ProviderInterface, - TransactionType, } from "starknet" +import { TransactionType } from "starknet" import { isAccountDeployed } from "../../../accountDeploy" import type { EstimatedFees } from "@argent/x-shared/simulation" import type { WalletAccount } from "../../../../shared/wallet.model" import type { Wallet } from "../../../wallet" import type { Address } from "@argent/x-shared" +import { walletAccountToArgentAccount } from "../../../../shared/utils/isExternalAccount" type Invocation = Invocations[number] @@ -99,9 +100,11 @@ export async function extendInvocationsByAccountDeploy( return invocations } + const argentAccount = walletAccountToArgentAccount(walletAccount) + const deployAccountInvocation: Invocations[number] = { type: TransactionType.DEPLOY_ACCOUNT, - payload: await wallet.getAccountDeploymentPayload(walletAccount), + payload: await wallet.getAccountDeploymentPayload(argentAccount), } return [deployAccountInvocation, ...invocations] diff --git a/packages/extension/src/background/trpc/procedures/transactionReview/getTransactionHash.ts b/packages/extension/src/background/trpc/procedures/transactionReview/getTransactionHash.ts deleted file mode 100644 index 224975769..000000000 --- a/packages/extension/src/background/trpc/procedures/transactionReview/getTransactionHash.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { z } from "zod" - -import { openSessionMiddleware } from "../../middleware/session" -import { extensionOnlyProcedure } from "../permissions" -import { estimatedFeesSchema } from "@argent/x-shared/simulation" -import { baseWalletAccountSchema } from "../../../../shared/wallet.model" -import { bigNumberishSchema, callSchema, hexSchema } from "@argent/x-shared" - -const getTransactionHashSchema = z.object({ - account: baseWalletAccountSchema, - transactions: z.array(callSchema).or(callSchema), - estimatedFees: estimatedFeesSchema.optional(), - nonce: bigNumberishSchema.optional(), -}) - -export const getTransactionHashProcedure = extensionOnlyProcedure - .use(openSessionMiddleware) - .input(getTransactionHashSchema) - .output(hexSchema.nullable()) - .query( - async ({ - ctx: { - services: { transactionReviewService }, - }, - input: { account, transactions, estimatedFees, nonce }, - }) => { - return transactionReviewService.getTransactionHash( - account, - transactions, - estimatedFees, - nonce, - ) - }, - ) diff --git a/packages/extension/src/background/trpc/procedures/transactionReview/index.ts b/packages/extension/src/background/trpc/procedures/transactionReview/index.ts index c97405b10..c8b658bec 100644 --- a/packages/extension/src/background/trpc/procedures/transactionReview/index.ts +++ b/packages/extension/src/background/trpc/procedures/transactionReview/index.ts @@ -1,13 +1,11 @@ import { router } from "../../trpc" import { getCompressedTransactionPayloadProcedure } from "./getCompressedTransactionPayload" import { getLabelsProcedure } from "./getLabels" -import { getTransactionHashProcedure } from "./getTransactionHash" import { getWarningsProcedure } from "./getWarnings" import { simulateAndReviewProcedure } from "./simulateAndReview" export const transactionReviewRouter = router({ simulateAndReview: simulateAndReviewProcedure, - getTransactionHash: getTransactionHashProcedure, getCompressedTransactionPayload: getCompressedTransactionPayloadProcedure, getLabels: getLabelsProcedure, getWarnings: getWarningsProcedure, diff --git a/packages/extension/src/background/trpc/procedures/transactionReview/simulateAndReview.ts b/packages/extension/src/background/trpc/procedures/transactionReview/simulateAndReview.ts index 3dbc27287..5f7fc940e 100644 --- a/packages/extension/src/background/trpc/procedures/transactionReview/simulateAndReview.ts +++ b/packages/extension/src/background/trpc/procedures/transactionReview/simulateAndReview.ts @@ -2,14 +2,19 @@ import { z } from "zod" import { openSessionMiddleware } from "../../middleware/session" import { extensionOnlyProcedure } from "../permissions" -import { transactionReviewTransactionsSchema } from "../../../../shared/transactionReview/interface" import { enrichedSimulateAndReviewSchema } from "@argent/x-shared/simulation" import { addressSchema } from "@argent/x-shared" +import { + accountDeployTransactionSchema, + transactionActionSchema, +} from "../../../../shared/transactionReview/transactionAction.model" const simulateAndReviewSchema = z.object({ feeTokenAddress: addressSchema, - transactions: z.array(transactionReviewTransactionsSchema), + transaction: transactionActionSchema, + accountDeployTransaction: accountDeployTransactionSchema.optional(), appDomain: z.string().optional(), + maxSendEstimate: z.boolean().optional(), }) export const simulateAndReviewProcedure = extensionOnlyProcedure diff --git a/packages/extension/src/background/trpc/procedures/transfer/send.ts b/packages/extension/src/background/trpc/procedures/transfer/send.ts index dad036875..19fed387d 100644 --- a/packages/extension/src/background/trpc/procedures/transfer/send.ts +++ b/packages/extension/src/background/trpc/procedures/transfer/send.ts @@ -49,7 +49,7 @@ export const sendProcedure = extensionOnlyProcedure title, shortTitle, subtitle, - icon: "SendIcon", + icon: "SendSecondaryIcon", }, ) diff --git a/packages/extension/src/background/trpc/procedures/udc/declareContractProcedure.ts b/packages/extension/src/background/trpc/procedures/udc/declareContractProcedure.ts index f12a461e0..fb4520d91 100644 --- a/packages/extension/src/background/trpc/procedures/udc/declareContractProcedure.ts +++ b/packages/extension/src/background/trpc/procedures/udc/declareContractProcedure.ts @@ -9,16 +9,13 @@ export const declareContractProcedure = extensionOnlyProcedure .output(z.string()) .mutation( async ({ - input: { address, networkId, ...rest }, + input: { accountId, ...rest }, ctx: { services: { actionService, wallet }, }, }) => { - if (address && networkId) { - await wallet.selectAccount({ - address, - networkId, - }) + if (accountId) { + await wallet.selectAccount(accountId) } try { const action = await actionService.add( @@ -33,7 +30,7 @@ export const declareContractProcedure = extensionOnlyProcedure }, ) return action.meta.hash - } catch (e) { + } catch { throw new UdcError({ code: "NO_DEPLOY_CONTRACT" }) } }, diff --git a/packages/extension/src/background/trpc/procedures/udc/deployContractProcedure.ts b/packages/extension/src/background/trpc/procedures/udc/deployContractProcedure.ts index d6667ccbf..4422680a9 100644 --- a/packages/extension/src/background/trpc/procedures/udc/deployContractProcedure.ts +++ b/packages/extension/src/background/trpc/procedures/udc/deployContractProcedure.ts @@ -6,19 +6,12 @@ export const deployContractProcedure = extensionOnlyProcedure .input(deployContractSchema) .mutation( async ({ - input: { - address, - networkId, - classHash, - constructorCalldata, - salt, - unique, - }, + input: { accountId, classHash, constructorCalldata, salt, unique }, ctx: { services: { actionService, wallet }, }, }) => { - await wallet.selectAccount({ address, networkId }) + await wallet.selectAccount(accountId) try { await actionService.add( { @@ -34,7 +27,7 @@ export const deployContractProcedure = extensionOnlyProcedure icon: "DocumentIcon", }, ) - } catch (e) { + } catch { throw new UdcError({ code: "NO_DEPLOY_CONTRACT" }) } }, diff --git a/packages/extension/src/background/trpc/procedures/udc/getConstructorParams.ts b/packages/extension/src/background/trpc/procedures/udc/getConstructorParams.ts index dbb0fa7a4..d27cd92ce 100644 --- a/packages/extension/src/background/trpc/procedures/udc/getConstructorParams.ts +++ b/packages/extension/src/background/trpc/procedures/udc/getConstructorParams.ts @@ -4,10 +4,10 @@ import { getProvider } from "../../../../shared/network" import { networkService } from "../../../../shared/network/service" import { extensionOnlyProcedure } from "../permissions" import { UdcError } from "../../../../shared/errors/udc" +import type { BasicContractClass } from "../../../../shared/udc/schema" import { getConstructorParamsSchema, basicContractClassSchema, - BasicContractClass, } from "../../../../shared/udc/schema" export const getConstructorParamsProcedure = extensionOnlyProcedure diff --git a/packages/extension/src/background/trpc/procedures/ui/index.ts b/packages/extension/src/background/trpc/procedures/ui/index.ts new file mode 100644 index 000000000..ced297e61 --- /dev/null +++ b/packages/extension/src/background/trpc/procedures/ui/index.ts @@ -0,0 +1,6 @@ +import { router } from "../../trpc" +import { openUIProcedure } from "./openUI" + +export const uiRouter = router({ + openUiAsPopup: openUIProcedure, +}) diff --git a/packages/extension/src/background/trpc/procedures/ui/openUI.ts b/packages/extension/src/background/trpc/procedures/ui/openUI.ts new file mode 100644 index 000000000..b18de79de --- /dev/null +++ b/packages/extension/src/background/trpc/procedures/ui/openUI.ts @@ -0,0 +1,19 @@ +import { z } from "zod" + +import { openSessionMiddleware } from "../../middleware/session" +import { extensionOnlyProcedure } from "../permissions" + +const urlSchema = z.string().optional() + +export const openUIProcedure = extensionOnlyProcedure + .use(openSessionMiddleware) + .input(urlSchema) + .query( + async ({ + ctx: { + services: { backgroundUIService }, + }, + }) => { + return backgroundUIService.openUiAsFloatingWindow() + }, + ) diff --git a/packages/extension/src/background/trpc/router.ts b/packages/extension/src/background/trpc/router.ts index e0bb57d32..e72b6c319 100644 --- a/packages/extension/src/background/trpc/router.ts +++ b/packages/extension/src/background/trpc/router.ts @@ -45,10 +45,17 @@ import { uiService } from "../../shared/ui" import { onboardingRouter } from "./procedures/onboarding" import { onboardingService } from "../services/onboarding" import { preAuthorizationRouter } from "./procedures/preAuthorization" +import { accountImportSharedService } from "../../shared/accountImport/service" +import { importAccountRouter } from "./procedures/importAccount" import { onRampRouter } from "./procedures/onramp" import { onRampService } from "../services/onRamp" import { notificationsRouter } from "./procedures/notifications" import { notificationService } from "../services/notifications" +import { uiRouter } from "./procedures/ui" +import { stakingService } from "../services/staking" +import { stakingRouter } from "./procedures/staking" +import { investmentService } from "../services/investments" +import { investmentsRouter } from "./procedures/investments" const appRouter = router({ account: accountRouter, @@ -75,8 +82,12 @@ const appRouter = router({ dappMessaging: dappMessagingRouter, onboarding: onboardingRouter, preAuthorization: preAuthorizationRouter, + importAccount: importAccountRouter, onramp: onRampRouter, notifications: notificationsRouter, + ui: uiRouter, + staking: stakingRouter, + investments: investmentsRouter, }) export type AppRouter = typeof appRouter @@ -107,8 +118,11 @@ createChromeHandler({ backgroundUIService, uiService, onboardingService, + importAccountService: accountImportSharedService, onRampService, notificationService, + stakingService, + investmentService, }, }), }) diff --git a/packages/extension/src/background/trpc/trpc.ts b/packages/extension/src/background/trpc/trpc.ts index a712b513b..c19d3b112 100644 --- a/packages/extension/src/background/trpc/trpc.ts +++ b/packages/extension/src/background/trpc/trpc.ts @@ -22,8 +22,11 @@ import type { IBackgroundMultisigService } from "../services/multisig/IBackgroun import type { IOnboardingService } from "../../shared/onboarding/IOnboardingService" import type { IBackgroundUIService } from "../services/ui/IBackgroundUIService" import type { Wallet } from "../wallet" -import { IOnRampService } from "../../shared/onRamp/IOnRampService" -import { INotificationService } from "../../shared/notifications/INotificationService" +import type { IAccountImportSharedService } from "../../shared/accountImport/service/IAccountImportSharedService" +import type { IOnRampService } from "../../shared/onRamp/IOnRampService" +import type { INotificationService } from "../../shared/notifications/INotificationService" +import type { IStakingService } from "../../shared/staking/IStakingService" +import type { IInvestmentService } from "../../shared/investments/IInvestmentService" interface Context { sender?: chrome.runtime.MessageSender @@ -48,8 +51,11 @@ interface Context { backgroundUIService: IBackgroundUIService uiService: IUIService onboardingService: IOnboardingService + importAccountService: IAccountImportSharedService onRampService: IOnRampService notificationService: INotificationService + stakingService: IStakingService + investmentService: IInvestmentService } } diff --git a/packages/extension/src/background/udcAction.ts b/packages/extension/src/background/udcAction.ts index 82824ccb1..b3c3f40a5 100644 --- a/packages/extension/src/background/udcAction.ts +++ b/packages/extension/src/background/udcAction.ts @@ -1,17 +1,13 @@ -import { - CallData, +import type { DeclareContractPayload, - TransactionType, UniversalDeployerContractPayload, - constants, - num, } from "starknet" +import { CallData, TransactionType, constants, num } from "starknet" -import { ExtQueueItem } from "../shared/actionQueue/types" +import type { ExtQueueItem } from "../shared/actionQueue/types" import { isAccountDeployed } from "./accountDeploy" -import { getNonce, increaseStoredNonce } from "./nonce" import { addTransaction } from "../shared/transactions/store" -import { Wallet } from "./wallet" +import type { Wallet } from "./wallet" import { AccountError } from "../shared/errors/account" import { WalletError } from "../shared/errors/wallet" import { UdcError } from "../shared/errors/udc" @@ -24,6 +20,9 @@ import { estimatedFeeToMaxResourceBounds, } from "@argent/x-shared" import { sanitizeAccountType } from "../shared/utils/sanitizeAccountType" +import { isArgentAccount } from "../shared/utils/isExternalAccount" +import { nonceManagementService } from "./nonceManagement" +import { addTransactionHash } from "../shared/transactions/transactionHashes/transactionHashesRepository" const { UDC } = constants @@ -43,7 +42,7 @@ export enum UdcTransactionType { } export const udcDeclareContract = async ( - { payload }: DeclareContractAction, + { payload, meta }: DeclareContractAction, wallet: Wallet, ) => { if (!(await wallet.isSessionOpen())) { @@ -54,10 +53,7 @@ export const udcDeclareContract = async ( throw new AccountError({ code: "NOT_SELECTED" }) } - const starknetAccount = await wallet.getStarknetAccount({ - address: selectedAccount.address, - networkId: selectedAccount.networkId, - }) + const starknetAccount = await wallet.getStarknetAccount(selectedAccount.id) const preComputedFees = await getEstimatedFees({ type: TransactionType.DECLARE, @@ -80,17 +76,29 @@ export const udcDeclareContract = async ( const declareNonce = accountNeedsDeploy ? num.toHex(1) - : await getNonce(selectedAccount, starknetAccount) + : await nonceManagementService.getNonce(selectedAccount.id) + + if ( + isArgentAccount(selectedAccount) && + accountNeedsDeploy && + preComputedFees.deployment + ) { + const deployDetails = { + version, + ...estimatedFeeToMaxResourceBounds(preComputedFees.deployment), + } - if (accountNeedsDeploy && preComputedFees.deployment) { - const { account, txHash: accountDeployTxHash } = await wallet.deployAccount( + const deployTxHash = await wallet.getDeployAccountTransactionHash( selectedAccount, - { - version, - ...estimatedFeeToMaxResourceBounds(preComputedFees.deployment), - }, + deployDetails, ) + await addTransactionHash(meta.hash, deployTxHash) + + const { account, txHash: accountDeployTxHash } = await wallet.deployAccount( + selectedAccount, + deployDetails, + ) if (!checkTransactionHash(accountDeployTxHash)) { throw new UdcError({ code: "DEPLOY_TX_NOT_ADDED" }) } @@ -125,7 +133,7 @@ export const udcDeclareContract = async ( throw new UdcError({ code: "CONTRACT_ALREADY_DECLARED" }) } - await increaseStoredNonce(selectedAccount) + await nonceManagementService.increaseLocalNonce(selectedAccount.id) await addTransaction({ hash: declareTxHash, @@ -154,7 +162,7 @@ export const udcDeclareContract = async ( } export const udcDeployContract = async ( - { payload }: DeployContractAction, + { payload, meta }: DeployContractAction, wallet: Wallet, ) => { if (!(await wallet.isSessionOpen())) { @@ -166,10 +174,7 @@ export const udcDeployContract = async ( throw new AccountError({ code: "NOT_SELECTED" }) } - const starknetAccount = await wallet.getStarknetAccount({ - address: selectedAccount.address, - networkId: selectedAccount.networkId, - }) + const starknetAccount = await wallet.getStarknetAccount(selectedAccount.id) const preComputedFees = await getEstimatedFees({ type: TransactionType.DEPLOY, @@ -191,15 +196,28 @@ export const udcDeployContract = async ( const deployNonce = accountNeedsDeploy ? num.toHex(num.toBigInt(1)) - : await getNonce(selectedAccount, starknetAccount) + : await nonceManagementService.getNonce(selectedAccount.id) + + if ( + isArgentAccount(selectedAccount) && + accountNeedsDeploy && + preComputedFees.deployment + ) { + const deployDetails = { + version, + ...estimatedFeeToMaxResourceBounds(preComputedFees.deployment), + } + + const deployTxHash = await wallet.getDeployAccountTransactionHash( + selectedAccount, + deployDetails, + ) + + await addTransactionHash(meta.hash, deployTxHash) - if (accountNeedsDeploy && preComputedFees.deployment) { const { account, txHash: accountDeployTxHash } = await wallet.deployAccount( selectedAccount, - { - version, - ...estimatedFeeToMaxResourceBounds(preComputedFees.deployment), - }, + deployDetails, ) if (!checkTransactionHash(accountDeployTxHash)) { @@ -274,7 +292,7 @@ export const udcDeployContract = async ( }) // transaction added, lets increase the local nonce, so we can queue transactions if needed - await increaseStoredNonce(selectedAccount) + await nonceManagementService.increaseLocalNonce(selectedAccount.id) return { txHash: deployTxHash, contractAddress } } diff --git a/packages/extension/src/background/udcMessaging.ts b/packages/extension/src/background/udcMessaging.ts index 14a529715..de1972f57 100644 --- a/packages/extension/src/background/udcMessaging.ts +++ b/packages/extension/src/background/udcMessaging.ts @@ -1,6 +1,7 @@ -import { UdcMessage } from "../shared/messages/UdcMessage" +import type { UdcMessage } from "../shared/messages/UdcMessage" -import { HandleMessage, UnhandledMessage } from "./background" +import type { HandleMessage } from "./background" +import { UnhandledMessage } from "./background" export const handleUdcMessaging: HandleMessage = async ({ msg, @@ -14,10 +15,7 @@ export const handleUdcMessaging: HandleMessage = async ({ const { data } = msg const { account, payload } = data if (account) { - await wallet.selectAccount({ - address: account.address, - networkId: account.networkId, - }) + await wallet.selectAccount(account.id) } const action = await actionService.add( diff --git a/packages/extension/src/background/wallet/account/WalletAccountStarknetService.test.ts b/packages/extension/src/background/wallet/account/WalletAccountStarknetService.test.ts index 4f05a8919..f68f42c86 100644 --- a/packages/extension/src/background/wallet/account/WalletAccountStarknetService.test.ts +++ b/packages/extension/src/background/wallet/account/WalletAccountStarknetService.test.ts @@ -1,14 +1,12 @@ -import { WalletAccountStarknetService } from "./WalletAccountStarknetService" -import { WalletSessionService } from "../session/WalletSessionService" -import { WalletAccountSharedService } from "../../../shared/account/service/accountSharedService/WalletAccountSharedService" -import { WalletCryptoStarknetService } from "../crypto/WalletCryptoStarknetService" +import type { WalletAccountStarknetService } from "./WalletAccountStarknetService" +import type { WalletSessionService } from "../session/WalletSessionService" +import type { WalletAccountSharedService } from "../../../shared/account/service/accountSharedService/WalletAccountSharedService" +import type { WalletCryptoStarknetService } from "../crypto/WalletCryptoStarknetService" import { MultisigAccount } from "../../../shared/multisig/account" import { - accountSharedServiceMock, accountStarknetServiceMock, cryptoStarknetServiceMock, sessionServiceMock, - ledgerServiceMock, } from "../test.utils" import { Account, stark } from "starknet" import { @@ -22,6 +20,11 @@ import { cosignerSign } from "../../../shared/smartAccount/backend/account" import { addressSchema } from "@argent/x-shared" import { getBaseDerivationPath } from "../../../shared/signer/utils" import { SignerType } from "../../../shared/wallet.model" +import { getAccountIdentifier } from "../../../shared/utils/accountIdentifier" +import { + accountSharedServiceMock, + ledgerServiceMock, +} from "../../../shared/test.utils" // Mock dependencies vi.mock("../session/session.service") @@ -30,6 +33,13 @@ vi.mock("../crypto/starknet.service") const testAddress = addressSchema.parse(stark.randomAddress()) +const mockSigner = { + type: SignerType.LOCAL_SECRET, + derivationPath: "m/44'/60'/0'/0/0", +} + +const testId = getAccountIdentifier(testAddress, "net1", mockSigner) + describe("AccountStarknetService", () => { let accountStarknetService: WalletAccountStarknetService let sessionService: WalletSessionService @@ -48,10 +58,7 @@ describe("AccountStarknetService", () => { vi.spyOn(sessionService, "isSessionOpen").mockResolvedValue(false) await expect( - accountStarknetService.getStarknetAccount({ - address: testAddress, - networkId: "net1", - }), + accountStarknetService.getStarknetAccount(testId), ).rejects.toThrow("no open session") }) @@ -60,10 +67,7 @@ describe("AccountStarknetService", () => { vi.spyOn(accountSharedService, "getAccount").mockResolvedValue(null) await expect( - accountStarknetService.getStarknetAccount({ - address: testAddress, - networkId: "net1", - }), + accountStarknetService.getStarknetAccount(testId), ).rejects.toThrow("Account not found") }) }) diff --git a/packages/extension/src/background/wallet/account/WalletAccountStarknetService.ts b/packages/extension/src/background/wallet/account/WalletAccountStarknetService.ts index 8b57a8ebc..4040d4d19 100644 --- a/packages/extension/src/background/wallet/account/WalletAccountStarknetService.ts +++ b/packages/extension/src/background/wallet/account/WalletAccountStarknetService.ts @@ -1,37 +1,42 @@ import { getProvider } from "../../../shared/network" -import { Account, CairoVersion } from "starknet" +import type { CairoVersion } from "starknet" +import { Account } from "starknet" import { MultisigAccount } from "../../../shared/multisig/account" -import { PendingMultisig } from "../../../shared/multisig/types" -import { INetworkService } from "../../../shared/network/service/INetworkService" -import { IRepository } from "../../../shared/storage/__new/interface" +import type { PendingMultisig } from "../../../shared/multisig/types" +import type { INetworkService } from "../../../shared/network/service/INetworkService" +import type { IRepository } from "../../../shared/storage/__new/interface" import { getAccountCairoVersion } from "../../../shared/utils/argentAccountVersion" -import { - BaseWalletAccount, +import type { + AccountId, ImportedLedgerAccount, - SignerType, WalletAccount, } from "../../../shared/wallet.model" -import { WalletCryptoStarknetService } from "../crypto/WalletCryptoStarknetService" -import { WalletSessionService } from "../session/WalletSessionService" -import { WalletAccountSharedService } from "../../../shared/account/service/accountSharedService/WalletAccountSharedService" +import { SignerType } from "../../../shared/wallet.model" +import type { WalletCryptoStarknetService } from "../crypto/WalletCryptoStarknetService" +import type { WalletSessionService } from "../session/WalletSessionService" +import type { WalletAccountSharedService } from "../../../shared/account/service/accountSharedService/WalletAccountSharedService" import { SessionError } from "../../../shared/errors/session" import { AccountError } from "../../../shared/errors/account" -import { IMultisigBackendService } from "../../../shared/multisig/service/backend/IMultisigBackendService" +import type { IMultisigBackendService } from "../../../shared/multisig/service/backend/IMultisigBackendService" import { SmartAccount } from "../../../shared/smartAccount/account" -import { BaseSignerInterface } from "../../../shared/signer/BaseSignerInterface" +import type { BaseSignerInterface } from "../../../shared/signer/BaseSignerInterface" import { StarknetAccount } from "../../../shared/starknetAccount" import { cosignerSign } from "../../../shared/smartAccount/backend/account" -import { ILedgerSharedService } from "../../../shared/ledger/service/ILedgerSharedService" +import type { ILedgerSharedService } from "../../../shared/ledger/service/ILedgerSharedService" import { union } from "lodash-es" import { getCairo1AccountContractAddress } from "../../../shared/utils/getContractAddress" -import { getBaseDerivationPath } from "../../../shared/signer/utils" +import { getDerivationPathForIndex } from "../../../shared/signer/utils" import { isContractDeployed, getLatestLedgerAccountClassHash, getLedgerAccountClassHashes, } from "@argent/x-shared" -import { BaseStarknetAccount } from "../../../shared/starknetAccount/base" +import type { BaseStarknetAccount } from "../../../shared/starknetAccount/base" +import { getAccountIdentifier } from "../../../shared/utils/accountIdentifier" +import type { IAccountImportSharedService } from "../../../shared/accountImport/service/IAccountImportSharedService" +import type { ValidatedImport } from "../../../shared/accountImport/types" +import { ImportedAccount } from "../../../shared/accountImport/account" export class WalletAccountStarknetService { constructor( @@ -42,16 +47,17 @@ export class WalletAccountStarknetService { private readonly cryptoStarknetService: WalletCryptoStarknetService, private readonly multisigBackendService: IMultisigBackendService, private readonly ledgerService: ILedgerSharedService, + private readonly importedAccountService: IAccountImportSharedService, ) {} public async getStarknetAccount( - selector: BaseWalletAccount, + accountId: AccountId, useLatest = false, ): Promise { if (!(await this.sessionService.isSessionOpen())) { throw new SessionError({ code: "NO_OPEN_SESSION" }) } - const account = await this.accountSharedService.getAccount(selector) + const account = await this.accountSharedService.getAccount(accountId) if (!account) { throw new AccountError({ code: "NOT_FOUND" }) } @@ -59,7 +65,7 @@ export class WalletAccountStarknetService { const provider = getProvider( account.network && account.network.rpcUrl ? account.network - : await this.networkService.getById(selector.networkId), + : await this.networkService.getById(account.networkId), ) const signer = await this.cryptoStarknetService.getSignerForAccount(account) @@ -69,7 +75,7 @@ export class WalletAccountStarknetService { if (account.needsDeploy) { cairoVersion = await this.cryptoStarknetService.getUndeployedAccountCairoVersion( - selector, + account, ) } else if (useLatest) { cairoVersion = "1" @@ -111,7 +117,7 @@ export class WalletAccountStarknetService { throw new Error("no selected account") } - return this.getStarknetAccount(account) + return this.getStarknetAccount(account.id) } public async newPendingMultisig( @@ -163,6 +169,13 @@ export class WalletAccountStarknetService { cosignerSign, ) + case "imported": + return ImportedAccount.fromAccount( + account, + signer, + walletAccount.classHash, + ) + default: return StarknetAccount.fromAccount( account, @@ -198,13 +211,21 @@ export class WalletAccountStarknetService { return pubKeys.flatMap(({ pubKey, index }) => classHashes.map((classHash) => { + const address = getCairo1AccountContractAddress(classHash, pubKey) + const signer = { + type: SignerType.LEDGER as const, + derivationPath: getDerivationPathForIndex( + index, + SignerType.LEDGER, + "standard", + ), + } + return { - address: getCairo1AccountContractAddress(classHash, pubKey), + id: getAccountIdentifier(address, networkId, signer), + address, networkId, - signer: { - type: SignerType.LEDGER as const, - derivationPath: `${getBaseDerivationPath("standard", SignerType.LEDGER)}/${index}`, - }, + signer, } }), ) @@ -243,4 +264,17 @@ export class WalletAccountStarknetService { return ledgerAccountsToWalletAccounts } + + async importAccount(account: ValidatedImport) { + const session = await this.sessionService.sessionStore.get() + + if (!session) { + throw new SessionError({ code: "NO_OPEN_SESSION" }) + } + + return await this.importedAccountService.importAccount( + account, + session.password, + ) + } } diff --git a/packages/extension/src/background/wallet/backup/WalletBackupService.ts b/packages/extension/src/background/wallet/backup/WalletBackupService.ts index 880857fea..9a7f1ab4a 100644 --- a/packages/extension/src/background/wallet/backup/WalletBackupService.ts +++ b/packages/extension/src/background/wallet/backup/WalletBackupService.ts @@ -1,9 +1,9 @@ -import { INetworkService } from "../../../shared/network/service/INetworkService" -import { +import type { INetworkService } from "../../../shared/network/service/INetworkService" +import type { IObjectStore, IRepository, } from "../../../shared/storage/__new/interface" -import { +import type { BaseWalletAccount, NetworkOnlyPlaceholderAccount, WalletAccount, diff --git a/packages/extension/src/background/wallet/crypto/WalletCryptoSharedService.ts b/packages/extension/src/background/wallet/crypto/WalletCryptoSharedService.ts index a501d77eb..72d5d8be4 100644 --- a/packages/extension/src/background/wallet/crypto/WalletCryptoSharedService.ts +++ b/packages/extension/src/background/wallet/crypto/WalletCryptoSharedService.ts @@ -1,4 +1,4 @@ -import { WalletBackupService } from "../backup/WalletBackupService" +import type { WalletBackupService } from "../backup/WalletBackupService" import { decryptKeystoreJson, @@ -7,11 +7,11 @@ import { Mnemonic, } from "ethers" import { defaultNetwork } from "../../../shared/network" -import { WalletRecoverySharedService } from "../recovery/WalletRecoverySharedService" -import { WalletSessionService } from "../session/WalletSessionService" +import type { WalletRecoverySharedService } from "../recovery/WalletRecoverySharedService" +import type { WalletSessionService } from "../session/WalletSessionService" import type { WalletSession } from "../session/walletSession.model" -import { IWalletDeploymentService } from "../deployment/IWalletDeploymentService" -import { IObjectStore } from "../../../shared/storage/__new/interface" +import type { IWalletDeploymentService } from "../deployment/IWalletDeploymentService" +import type { IObjectStore } from "../../../shared/storage/__new/interface" import { walletToKeystore } from "../utils" export class WalletCryptoSharedService { diff --git a/packages/extension/src/background/wallet/crypto/WalletCryptoStarknetService.ts b/packages/extension/src/background/wallet/crypto/WalletCryptoStarknetService.ts index 8f48a9c6d..b6efd0b69 100644 --- a/packages/extension/src/background/wallet/crypto/WalletCryptoStarknetService.ts +++ b/packages/extension/src/background/wallet/crypto/WalletCryptoStarknetService.ts @@ -1,61 +1,80 @@ +import type { Hex, Implementation } from "@argent/x-shared" import { - Hex, - Implementation, + decodeBase58Array, + ensureArray, findImplementationForAccount, + getLatestArgentAccountClassHash, hexSchema, } from "@argent/x-shared" -import { CairoVersion, CallData, hash } from "starknet" +import type { CairoVersion } from "starknet" +import { CallData, hash } from "starknet" import { withHiddenSelector } from "../../../shared/account/selectors" -import { PendingMultisig } from "../../../shared/multisig/types" +import type { WalletAccountSharedService } from "../../../shared/account/service/accountSharedService/WalletAccountSharedService" +import { C0_PROXY_CONTRACT_CLASS_HASHES } from "../../../shared/account/starknet.constants" +import { AccountError } from "../../../shared/errors/account" +import { SessionError } from "../../../shared/errors/session" +import type { ILedgerSharedService } from "../../../shared/ledger/service/ILedgerSharedService" +import type { PendingMultisig } from "../../../shared/multisig/types" +import { getMultisigAccountFromBaseWallet } from "../../../shared/multisig/utils/baseMultisig" +import type { Network } from "../../../shared/network" +import { ArgentSigner } from "../../../shared/signer/ArgentSigner" +import type { BaseSignerInterface } from "../../../shared/signer/BaseSignerInterface" import { - WalletAccount, - BaseWalletAccount, - BaseMultisigWalletAccount, - ArgentAccountType, - SignerType, - WalletAccountSigner, - CreateAccountType, -} from "../../../shared/wallet.model" + getBaseDerivationPath, + getDerivationPathForIndex, +} from "../../../shared/signer/utils" +import type { + IObjectStore, + IRepository, +} from "../../../shared/storage/__new/interface" import { getIndexForPath, - getNextPathIndex, getPathForIndex, } from "../../../shared/utils/derivationPath" -import { WalletAccountSharedService } from "../../../shared/account/service/accountSharedService/WalletAccountSharedService" -import { getMultisigAccountFromBaseWallet } from "../../../shared/multisig/utils/baseMultisig" -import type { WalletSession } from "../session/walletSession.model" -import { Network } from "../../../shared/network" +import type { + AccountId, + ArgentAccountType, + BaseMultisigWalletAccount, + BaseWalletAccount, + CreateAccountType, + WalletAccount, + WalletAccountType, +} from "../../../shared/wallet.model" +import { SignerType } from "../../../shared/wallet.model" import { - getPreDeployedAccount, declareContracts, + getPreDeployedAccount, } from "../../devnet/declareAccounts" -import { LoadContracts } from "../loadContracts" -import { - IObjectStore, - IRepository, -} from "../../../shared/storage/__new/interface" -import { - decodeBase58Array, - getLatestArgentAccountClassHash, -} from "@argent/x-shared" -import { SessionError } from "../../../shared/errors/session" -import { AccountError } from "../../../shared/errors/account" -import { C0_PROXY_CONTRACT_CLASS_HASHES } from "../../../shared/account/starknet.constants" -import { ArgentSigner } from "../../../shared/signer/ArgentSigner" -import { BaseSignerInterface } from "../../../shared/signer/BaseSignerInterface" -import { getBaseDerivationPath } from "../../../shared/signer/utils" -import { ILedgerSharedService } from "../../../shared/ledger/service/ILedgerSharedService" +import type { LoadContracts } from "../loadContracts" +import type { WalletSession } from "../session/walletSession.model" +import { PrivateKeySigner } from "../../../shared/signer/PrivateKeySigner" +import { deserializeAccountIdentifier } from "../../../shared/utils/accountIdentifier" +import type { IPKManager } from "../../../shared/accountImport/pkManager/IPKManager" +import memoize, { type Memoized } from "memoizee" + const { getSelectorFromName, calculateContractAddressFromHash } = hash +type PublicKeyGetter = ( + account: WalletAccount, +) => Promise<{ publicKey: string; account: WalletAccount }> + export class WalletCryptoStarknetService { + private memoizedGetPublicKey: PublicKeyGetter & Memoized + constructor( private readonly walletStore: IRepository, private readonly sessionStore: IObjectStore, private readonly pendingMultisigStore: IRepository, private readonly accountSharedService: WalletAccountSharedService, private readonly ledgerService: ILedgerSharedService, + private readonly pkManager: IPKManager, private readonly loadContracts: LoadContracts, - ) {} + ) { + this.memoizedGetPublicKey = memoize(this._getPublicKey.bind(this), { + promise: true, + normalizer: ([account]) => account.id, + }) + } public async getArgentPubKeyByDerivationPath(derivationPath: string) { const session = await this.sessionStore.get() @@ -66,10 +85,6 @@ export class WalletCryptoStarknetService { return signer.getPubKey() } - public getLedgerPubKeyByDerivationPath(derivationPath: string) { - return this.ledgerService.getPubKey(derivationPath) - } - public getPubKeyByDerivationPathForSigner( signerType: SignerType, derivationPath: string, @@ -78,46 +93,54 @@ export class WalletCryptoStarknetService { case SignerType.LOCAL_SECRET: return this.getArgentPubKeyByDerivationPath(derivationPath) case SignerType.LEDGER: - return this.getLedgerPubKeyByDerivationPath(derivationPath) + return this.ledgerService.getPubKey(derivationPath) default: throw new Error(`Unsupported signer type: ${signerType}`) } } - public getSignerForAccount(account: WalletAccount) { - return this.getSigner(account.signer) + public getSignerForAccount({ id, type }: Pick) { + return this.getSigner(id, type) } public async getSigner( - signer: WalletAccountSigner, + accountId: AccountId, + type: WalletAccountType, ): Promise { const session = await this.sessionStore.get() if (!session?.secret) { throw new SessionError({ code: "NO_OPEN_SESSION" }) } - - const { type, derivationPath } = signer - - switch (type) { + const { signer } = deserializeAccountIdentifier(accountId) + const derivationPath = getDerivationPathForIndex( + signer.index, + signer.type, + type, + ) + switch (signer.type) { case SignerType.LOCAL_SECRET: return new ArgentSigner(session.secret, derivationPath) case SignerType.LEDGER: return this.ledgerService.getSigner(derivationPath) + case SignerType.PRIVATE_KEY: { + const pk = await this.pkManager.retrieveDecryptedKey( + session.password, + accountId, + ) + return new PrivateKeySigner(pk) + } default: throw new Error("Unsupported signer type") } } - public async getPrivateKey( - baseWalletAccount: BaseWalletAccount, - ): Promise { + public async getPrivateKey(accountId: AccountId): Promise { const session = await this.sessionStore.get() if (session === null || !session?.secret) { throw new SessionError({ code: "NO_OPEN_SESSION" }) } - const account = - await this.accountSharedService.getAccount(baseWalletAccount) + const account = await this.accountSharedService.getAccount(accountId) if (!account) { throw new AccountError({ code: "NOT_SELECTED" }) @@ -128,21 +151,16 @@ export class WalletCryptoStarknetService { return signer.getPrivateKey() } - public async getPublicKey( - baseAccount?: BaseWalletAccount, - ): Promise<{ publicKey: string; account: BaseWalletAccount }> { - const account = baseAccount - ? await this.accountSharedService.getAccount(baseAccount) + public async getPublicKey(accountId?: string) { + const account = accountId + ? await this.accountSharedService.getAccount(accountId) : await this.accountSharedService.getSelectedAccount() if (!account) { throw new AccountError({ code: "NOT_SELECTED" }) } - const signer = await this.getSignerForAccount(account) - const publicKey = await signer.getPubKey() - - return { publicKey, account } + return this.memoizedGetPublicKey(account) } /** @@ -176,23 +194,36 @@ export class WalletCryptoStarknetService { ...pendingMultisigs, ] - const currentPaths = accountsOrPendingMultisigs - .filter( - (account) => - account.signer.type === signerType && account.networkId === networkId, + const derivationPathsBySignerType = accountsOrPendingMultisigs + .filter((account) => account.networkId === networkId) + .reduce( + (acc, account) => { + const { type, derivationPath } = account.signer + + if (!acc[type]) { + acc[type] = [] + } + + acc[type].push(derivationPath) + return acc + }, + {} as Record, ) - .map((account) => account.signer.derivationPath) const baseDerivationPath = getBaseDerivationPath(accountType, signerType) - const usedIndices = currentPaths.map((path) => - getIndexForPath(path, baseDerivationPath), - ) - const nextIndex = getNextPathIndex(currentPaths, baseDerivationPath) + const usedIndices = ensureArray( + derivationPathsBySignerType[signerType], + ).map((path) => getIndexForPath(path, baseDerivationPath)) + + // We consider all signers when determining the next available index to ensure that any changes in signers are accounted for, preventing the reuse of an index linked to a previously assigned address, which would result in a duplicate. + const nextIndex = accountsOrPendingMultisigs.length + const path = getPathForIndex(nextIndex, baseDerivationPath) switch (signerType) { case SignerType.LOCAL_SECRET: { const publicKey = await this.getArgentPubKeyByDerivationPath(path) + return { index: nextIndex, derivationPath: path, @@ -208,7 +239,6 @@ export class WalletCryptoStarknetService { usedIndices, networkId, ) - return { index, derivationPath: getPathForIndex(index, baseDerivationPath), @@ -294,7 +324,9 @@ export class WalletCryptoStarknetService { public async getUndeployedAccountCairoVersion( baseAccount: BaseWalletAccount, ): Promise { - const account = await this.accountSharedService.getAccount(baseAccount) + const account = await this.accountSharedService.getArgentAccount( + baseAccount.id, + ) if (!account) { throw new AccountError({ code: "NOT_FOUND" }) @@ -312,7 +344,7 @@ export class WalletCryptoStarknetService { return "1" // multisig is always Cairo 1 } - const { publicKey } = await this.getPublicKey(account) + const { publicKey } = await this.getPublicKey(account.id) // If no class hash is provided by the account, we want to add the network implementation to check const networkImplementation: Implementation = { @@ -349,7 +381,7 @@ export class WalletCryptoStarknetService { throw new AccountError({ code: "MULTISIG_NOT_FOUND" }) } - const { publicKey } = await this.getPublicKey(multisigAccount) + const { publicKey } = await this.getPublicKey(multisigAccount.id) const accountClassHash = multisigAccount.classHash ?? @@ -382,4 +414,10 @@ export class WalletCryptoStarknetService { 0, ) } + + private async _getPublicKey(account: WalletAccount) { + const signer = await this.getSignerForAccount(account) + const publicKey = await signer.getPubKey() + return { publicKey, account } + } } diff --git a/packages/extension/src/background/wallet/deployment/IWalletDeploymentService.ts b/packages/extension/src/background/wallet/deployment/IWalletDeploymentService.ts index 91205235d..5679efdeb 100644 --- a/packages/extension/src/background/wallet/deployment/IWalletDeploymentService.ts +++ b/packages/extension/src/background/wallet/deployment/IWalletDeploymentService.ts @@ -1,17 +1,17 @@ -import { +import type { CairoVersion, DeployAccountContractPayload as StarknetDeployAccountContractPayload, InvocationsDetails as StarknetInvocationDetails, } from "starknet" -import { +import type { CreateAccountType, CreateWalletAccount, MultisigData, SignerType, WalletAccount, } from "../../../shared/wallet.model" -import { Address } from "@argent/x-shared" -import { EstimatedFee } from "@argent/x-shared/simulation" +import type { Address, Hex } from "@argent/x-shared" +import type { EstimatedFee } from "@argent/x-shared/simulation" // Extend to support multichain type InvocationsDetails = StarknetInvocationDetails @@ -25,13 +25,14 @@ export interface IWalletDeploymentService { walletAccount: WalletAccount, transactionDetails?: InvocationsDetails | undefined, ): Promise<{ account: WalletAccount; txHash: string }> + getDeployAccountTransactionHash( + walletAccount: WalletAccount, + transactionDetails?: InvocationsDetails | undefined, + ): Promise getAccountDeploymentFee( walletAccount: WalletAccount, feeTokenAddress?: Address, ): Promise - redeployAccount( - account: WalletAccount, - ): Promise<{ account: WalletAccount; txHash: string }> getAccountDeploymentPayload( walletAccount: WalletAccount, ): Promise> diff --git a/packages/extension/src/background/wallet/deployment/WalletDeploymentStarknetService.test.ts b/packages/extension/src/background/wallet/deployment/WalletDeploymentStarknetService.test.ts index f2ff471a2..13a404116 100644 --- a/packages/extension/src/background/wallet/deployment/WalletDeploymentStarknetService.test.ts +++ b/packages/extension/src/background/wallet/deployment/WalletDeploymentStarknetService.test.ts @@ -11,20 +11,17 @@ import { import { ARGENT_API_BASE_URL } from "../../../shared/api/constants" import { tryToMintAllFeeTokens } from "../../../shared/devnet/mintFeeToken" import { SessionError } from "../../../shared/errors/session" -import { PendingMultisig } from "../../../shared/multisig/types" +import type { PendingMultisig } from "../../../shared/multisig/types" import { pendingMultisigEqual } from "../../../shared/multisig/utils/selectors" -import { - Network, - defaultNetwork, - defaultNetworks, -} from "../../../shared/network" +import type { Network } from "../../../shared/network" +import { defaultNetwork, defaultNetworks } from "../../../shared/network" import * as jwtService from "../../../shared/smartAccount/jwt" import { ArrayStorage, KeyValueStorage, ObjectStorage, } from "../../../shared/storage" -import { +import type { IObjectStore, IRepository, } from "../../../shared/storage/__new/interface" @@ -32,32 +29,36 @@ import { adaptKeyValue } from "../../../shared/storage/__new/keyvalue" import { adaptObjectStorage } from "../../../shared/storage/__new/object" import { adaptArrayStorage } from "../../../shared/storage/__new/repository" import { accountsEqual } from "../../../shared/utils/accountsEqual" -import { +import type { BaseMultisigWalletAccount, WalletAccount, } from "../../../shared/wallet.model" import { WalletAccountStarknetService } from "../account/WalletAccountStarknetService" -import { - WalletBackupService, - WalletStorageProps, -} from "../backup/WalletBackupService" +import type { WalletStorageProps } from "../backup/WalletBackupService" +import { WalletBackupService } from "../backup/WalletBackupService" import { WalletCryptoSharedService } from "../crypto/WalletCryptoSharedService" import { WalletCryptoStarknetService } from "../crypto/WalletCryptoStarknetService" import { WalletSessionService } from "../session/WalletSessionService" -import { WalletSession } from "../session/walletSession.model" +import type { WalletSession } from "../session/walletSession.model" import { - accountServiceMock, analyticsServiceMock, - emitterMock, getDefaultReferralService, - httpServiceMock, - multisigBackendServiceMock, recoverySharedServiceMock, recoveryStarknetServiceMock, } from "../test.utils" -import { INetworkService } from "../../../shared/network/service/INetworkService" +import type { INetworkService } from "../../../shared/network/service/INetworkService" import { WalletDeploymentStarknetService } from "./WalletDeploymentStarknetService" import { LedgerSharedService } from "../../../shared/ledger/service/LedgerSharedService" +import { stark } from "starknet" +import { AccountImportSharedService } from "../../../shared/accountImport/service/AccountImportSharedService" +import type { IPKStore } from "../../../shared/accountImport/types" +import { PKManager } from "../../../shared/accountImport/pkManager/PKManager" +import { + accountServiceMock, + emitterMock, + httpServiceMock, + multisigBackendServiceMock, +} from "../../../shared/test.utils" const networkService: Pick = { getById: async (networkId) => { @@ -99,6 +100,10 @@ const getPendingMultisigStore = ( }) } +const getPKStore = (name: string) => { + return new KeyValueStorage({ keystore: {} }, name) +} + const SCRYPT_N = 262144 const getWallet = (randId = Math.random()) => { @@ -117,6 +122,8 @@ const getWallet = (randId = Math.random()) => { getPendingMultisigStore(`test:multisig:pending:${randId}`), ) + const pkStore = adaptKeyValue(getPKStore(`test:pk:${randId}`)) + const defaultBackUpService = new WalletBackupService( storage, accountStore, @@ -137,12 +144,21 @@ const getWallet = (randId = Math.random()) => { multisigBackendServiceMock, ) + const pkManager = new PKManager(pkStore, SCRYPT_N) + + const importAccountService = new AccountImportSharedService( + accountServiceMock, + networkService, + pkManager, + ) + const defaultCryptoStarknetService = new WalletCryptoStarknetService( accountStore, sessionStore, pendingMultisigStore, defaultAccountSharedService, ledgerService, + pkManager, vi.fn(), ) @@ -163,12 +179,12 @@ const getWallet = (randId = Math.random()) => { defaultCryptoStarknetService, multisigBackendServiceMock, ledgerService, + importAccountService, ) const defaultDeployStarknetService = new WalletDeploymentStarknetService( accountStore, baseMultisigStore, - pendingMultisigStore, defaultSessionService, sessionStore, defaultAccountSharedService, @@ -204,7 +220,7 @@ const getWallet = (randId = Math.random()) => { return wallet } -const address = "0x123" +const address = stark.randomAddress() const BASE_URL_ENDPOINT = urlJoin(ARGENT_API_BASE_URL, "account") const addAccountJsonResponse = { address, diff --git a/packages/extension/src/background/wallet/deployment/WalletDeploymentStarknetService.ts b/packages/extension/src/background/wallet/deployment/WalletDeploymentStarknetService.ts index 589f0bf8b..2b220f46d 100644 --- a/packages/extension/src/background/wallet/deployment/WalletDeploymentStarknetService.ts +++ b/packages/extension/src/background/wallet/deployment/WalletDeploymentStarknetService.ts @@ -1,45 +1,44 @@ +import type { Address, Hex, Implementation } from "@argent/x-shared" import { - Address, - Implementation, + AddSmartAcountRequestSchema, addressSchema, + estimatedFeeToMaxResourceBounds, findImplementationForAccount, getAccountDeploymentPayload, + getTxVersionFromFeeToken, + hexSchema, isContractDeployed, isEqualAddress, - getTxVersionFromFeeToken, - estimatedFeeToMaxResourceBounds, - AddSmartAcountRequestSchema, } from "@argent/x-shared" -import { - CallData, +import type { DeployAccountContractTransaction, EstimateFeeDetails, - hash, - stark, } from "starknet" -import { PendingMultisig } from "../../../shared/multisig/types" +import { CallData, hash, stark, TransactionType } from "starknet" import { getMultisigAccountFromBaseWallet } from "../../../shared/multisig/utils/baseMultisig" import { getProvider } from "../../../shared/network/provider" -import { INetworkService } from "../../../shared/network/service/INetworkService" -import { +import type { INetworkService } from "../../../shared/network/service/INetworkService" +import type { IObjectStore, IRepository, } from "../../../shared/storage/__new/interface" -import { +import type { + ArgentWalletAccount, BaseMultisigWalletAccount, CreateAccountType, CreateWalletAccount, MultisigData, - SignerType, WalletAccount, } from "../../../shared/wallet.model" +import { SignerType } from "../../../shared/wallet.model" -import { WalletAccountSharedService } from "../../../shared/account/service/accountSharedService/WalletAccountSharedService" +import type { WalletAccountSharedService } from "../../../shared/account/service/accountSharedService/WalletAccountSharedService" import { stringToBytes } from "@scure/base" import { keccak, pedersen } from "micro-starknet" -import { BigNumberish, constants, num } from "starknet" -import { AnalyticsService } from "../../../shared/analytics/AnalyticsService" +import type { BigNumberish } from "starknet" +import { constants, num } from "starknet" +import type { AnalyticsService } from "../../../shared/analytics/AnalyticsService" import { AccountError } from "../../../shared/errors/account" import { SessionError } from "../../../shared/errors/session" import { WalletError } from "../../../shared/errors/wallet" @@ -48,27 +47,29 @@ import { STRK_TOKEN_ADDRESS, } from "../../../shared/network/constants" import { addBackendAccount } from "../../../shared/smartAccount/backend/account" -import { - getIndexForPath, - getPathForIndex, -} from "../../../shared/utils/derivationPath" -import { getNonce, increaseStoredNonce } from "../../nonce" -import { WalletAccountStarknetService } from "../account/WalletAccountStarknetService" -import { WalletBackupService } from "../backup/WalletBackupService" -import { WalletCryptoStarknetService } from "../crypto/WalletCryptoStarknetService" -import { IReferralService } from "../../services/referral/IReferralService" -import { WalletSessionService } from "../session/WalletSessionService" +import { getPathForIndex } from "../../../shared/utils/derivationPath" +import type { IReferralService } from "../../services/referral/IReferralService" +import type { WalletAccountStarknetService } from "../account/WalletAccountStarknetService" +import type { WalletBackupService } from "../backup/WalletBackupService" +import type { WalletCryptoStarknetService } from "../crypto/WalletCryptoStarknetService" +import type { WalletSessionService } from "../session/WalletSessionService" import type { WalletSession } from "../session/walletSession.model" -import { +import type { DeployAccountContractPayload, IWalletDeploymentService, } from "./IWalletDeploymentService" -import { EstimatedFee } from "@argent/x-shared/simulation" +import type { EstimatedFee } from "@argent/x-shared/simulation" +import type { BaseSignerInterface } from "../../../shared/signer/BaseSignerInterface" import { getBaseDerivationPath } from "../../../shared/signer/utils" -import { BaseSignerInterface } from "../../../shared/signer/BaseSignerInterface" import { sanitizeAccountType } from "../../../shared/utils/sanitizeAccountType" import { sanitizeSignerType } from "../../../shared/utils/sanitizeSignerType" +import { + deserializeAccountIdentifier, + getAccountIdentifier, +} from "../../../shared/utils/accountIdentifier" +import { ArgentSigner } from "../../../shared/signer" +import { addEstimatedFee } from "../../../shared/transactionSimulation/fees/estimatedFeesRepository" const { calculateContractAddressFromHash } = hash @@ -94,7 +95,6 @@ export class WalletDeploymentStarknetService constructor( private readonly walletStore: IRepository, private readonly multisigStore: IRepository, - private readonly pendingMultisigStore: IRepository, private readonly sessionService: WalletSessionService, public readonly sessionStore: IObjectStore, private readonly accountSharedService: WalletAccountSharedService, @@ -107,64 +107,42 @@ export class WalletDeploymentStarknetService ) {} public async deployAccount( - walletAccount: WalletAccount, + walletAccount: ArgentWalletAccount, transactionDetails?: EstimateFeeDetails | undefined, ): Promise<{ account: WalletAccount; txHash: string }> { - const starknetAccount = - await this.accountStarknetService.getStarknetAccount(walletAccount) - - if (!starknetAccount) { - throw new AccountError({ code: "NOT_FOUND" }) - } - - if (!("deployAccount" in starknetAccount)) { - throw new AccountError({ code: "CANNOT_DEPLOY_OLD_ACCOUNTS" }) - } - - const deployAccountPayload = - await this.getAccountOrMultisigDeploymentPayload(walletAccount) - - const estimatedFees = estimatedFeeToMaxResourceBounds( - await this.getAccountDeploymentFee( - walletAccount, - mapVersionToFeeToken(transactionDetails?.version ?? "0x1"), - ), + const { payload, details, account } = await this.buildDeploymentPayload( + walletAccount, + transactionDetails, ) + const { transaction_hash } = await account.deployAccount(payload, details) + + const { signer } = deserializeAccountIdentifier(walletAccount.id) - const maxFeeOrBounds = - transactionDetails?.maxFee || transactionDetails?.resourceBounds - ? { - maxFee: transactionDetails?.maxFee, - resourceBounds: transactionDetails?.resourceBounds, - } - : estimatedFees - - const { transaction_hash } = await starknetAccount.deployAccount( - deployAccountPayload, - { - ...transactionDetails, - ...maxFeeOrBounds, - }, - ) - const baseDerivationPath = getBaseDerivationPath( - walletAccount.type !== "multisig" ? "standard" : "multisig", - walletAccount.signer.type, - ) this.ampli.accountDeployed({ - "account index": getIndexForPath( - walletAccount.signer.derivationPath, - baseDerivationPath, - ), + "account index": signer.index, "account type": sanitizeAccountType(walletAccount.type), "wallet platform": "browser extension", }) - await this.accountSharedService.selectAccount(walletAccount) + await this.accountSharedService.selectAccount(walletAccount.id) return { account: walletAccount, txHash: transaction_hash } } + public async getDeployAccountTransactionHash( + walletAccount: ArgentWalletAccount, + transactionDetails?: EstimateFeeDetails | undefined, + ): Promise { + const { account, payload, details } = await this.buildDeploymentPayload( + walletAccount, + transactionDetails, + ) + + const hash = await account.getAccountDeployTransactionHash(payload, details) + return hexSchema.parse(hash) + } + public async getAccountOrMultisigDeploymentPayload( - walletAccount: WalletAccount, + walletAccount: ArgentWalletAccount, ) { if (walletAccount.type === "multisig") { return this.getMultisigDeploymentPayload(walletAccount) @@ -173,11 +151,11 @@ export class WalletDeploymentStarknetService } public async getAccountDeploymentFee( - walletAccount: WalletAccount, + walletAccount: ArgentWalletAccount, feeTokenAddress: Address, ): Promise { const starknetAccount = - await this.accountStarknetService.getStarknetAccount(walletAccount) + await this.accountStarknetService.getStarknetAccount(walletAccount.id) if (!("deployAccount" in starknetAccount)) { throw new AccountError({ code: "CANNOT_ESTIMATE_DEPLOY_OLD_ACCOUNTS" }) @@ -200,47 +178,31 @@ export class WalletDeploymentStarknetService }) } - return { + const fees = { feeTokenAddress, amount: gas_consumed, pricePerUnit: gas_price, dataGasConsumed: data_gas_consumed, dataGasPrice: data_gas_price, } - } - - public async redeployAccount(account: WalletAccount) { - if (!(await this.sessionService.isSessionOpen())) { - throw new SessionError({ code: "NO_OPEN_SESSION" }) - } - const starknetAccount = - await this.accountStarknetService.getStarknetAccount({ - address: account.address, - networkId: account.networkId, - }) - const nonce = await getNonce(account, starknetAccount) - const deployTransaction = await this.deployAccount(account, { nonce }) - - await increaseStoredNonce(account) - - return { account, txHash: deployTransaction.txHash } + await addEstimatedFee( + { transactions: fees }, + { type: TransactionType.DEPLOY_ACCOUNT, payload: deployAccountPayload }, + ) + return fees } /** Get the Account Deployment Payload * Use it in the deployAccount and getAccountDeploymentFee methods - * @param {WalletAccount} walletAccount + * @param {ArgentWalletAccount} walletAccount */ public async getAccountDeploymentPayload( - walletAccount: WalletAccount, + walletAccount: ArgentWalletAccount, ): Promise> { - const { address, network, signer, type, guardian, salt } = walletAccount + const { id, address, network, type, guardian, salt } = walletAccount - const publicKey = - await this.cryptoStarknetService.getPubKeyByDerivationPathForSigner( - signer.type, - signer.derivationPath, - ) + const { publicKey } = await this.cryptoStarknetService.getPublicKey(id) // If no class hash is provided by the account, we want to add the network implementation to check const networkImplementation: Implementation = { @@ -274,7 +236,7 @@ export class WalletDeploymentStarknetService } public async getMultisigDeploymentPayload( - walletAccount: WalletAccount, + walletAccount: ArgentWalletAccount, ): Promise> { const multisigAccount = await getMultisigAccountFromBaseWallet(walletAccount) @@ -283,13 +245,27 @@ export class WalletDeploymentStarknetService throw new AccountError({ code: "MULTISIG_NOT_FOUND" }) } - const { address, network, signer, threshold, signers } = multisigAccount - - const starkPub = - await this.cryptoStarknetService.getPubKeyByDerivationPathForSigner( - signer.type, - signer.derivationPath, - ) + const { id, address, network, threshold, signers } = multisigAccount + + const { publicKey } = await this.cryptoStarknetService.getPublicKey(id) + let addressSalt = publicKey + + // cIndex refers to corrupted index + // This should be removed in future asap + if ( + "cIndex" in multisigAccount && + typeof multisigAccount.cIndex === "number" + ) { + const corruptDerivationPath = + multisigAccount.signer.derivationPath.slice(0, -1) + + multisigAccount.cIndex + + addressSalt = + await this.cryptoStarknetService.getPubKeyByDerivationPathForSigner( + multisigAccount.signer.type, + corruptDerivationPath, + ) + } const accountClassHash = multisigAccount.classHash ?? @@ -305,7 +281,7 @@ export class WalletDeploymentStarknetService threshold, // Initial threshold signers, // Initial signers }), - addressSalt: starkPub, + addressSalt, } // Mostly we don't need to calculate the address, @@ -394,15 +370,13 @@ export class WalletDeploymentStarknetService public async getDeployContractPayloadForMultisig({ signers, threshold, - index, networkId, - signerType, + publicKey, }: { threshold: number signers: string[] - index: number networkId: string - signerType: SignerType + publicKey: string }): Promise, "signature">> { const hasSession = await this.sessionService.isSessionOpen() const initialised = await this.backupService.isInitialized() @@ -415,15 +389,6 @@ export class WalletDeploymentStarknetService } const network = await this.networkService.getById(networkId) - const path = getPathForIndex( - index, - getBaseDerivationPath("multisig", signerType), - ) - const pubKey = - await this.cryptoStarknetService.getPubKeyByDerivationPathForSigner( - signerType, - path, - ) const accountClassHash = await this.cryptoStarknetService.getAccountClassHashForNetwork( @@ -437,7 +402,7 @@ export class WalletDeploymentStarknetService threshold, // Initial threshold signers, // Initial signers }), - addressSalt: pubKey, + addressSalt: publicKey, } return payload @@ -454,8 +419,6 @@ export class WalletDeploymentStarknetService if (type === "multisig" && multisigPayload) { payload = await this.getDeployContractPayloadForMultisig({ - index, - signerType, networkId, ...multisigPayload, }) @@ -481,14 +444,33 @@ export class WalletDeploymentStarknetService signerType: SignerType = SignerType.LOCAL_SECRET, multisigPayload?: MultisigData, ): Promise { + const session = await this.sessionStore.get() + if (!session?.secret) { + throw new SessionError({ code: "NO_OPEN_SESSION" }) + } const network = await this.networkService.getById(networkId) - const { index, derivationPath, publicKey } = - await this.cryptoStarknetService.getNextPublicKey( + let index, derivationPath, publicKey + + if ( + multisigPayload && + multisigPayload.index !== undefined && + multisigPayload.derivationPath !== undefined && + multisigPayload.publicKey !== undefined + ) { + index = multisigPayload.index + derivationPath = multisigPayload.derivationPath + publicKey = multisigPayload.publicKey + } else { + const nextSigner = await this.cryptoStarknetService.getNextPublicKey( accountType, signerType, networkId, ) + index = nextSigner.index + derivationPath = nextSigner.derivationPath + publicKey = nextSigner.publicKey + } const payload = await this.getNewAccountDeploymentPayload( accountType, @@ -498,17 +480,13 @@ export class WalletDeploymentStarknetService multisigPayload, ) - const signer = await this.cryptoStarknetService.getSigner({ - type: signerType, - derivationPath, - }) - let accountAddress: string let guardianAddress: string | undefined let salt: string | undefined if (accountType === "smart") { - const signature = await signer.signRawMsgHash( + const _signer = new ArgentSigner(session.secret, derivationPath) // used temporarily to sign the account creation + const signature = await _signer.signRawMsgHash( pedersen(keccak(stringToBytes("utf8", "starknet")), publicKey), ) @@ -553,15 +531,20 @@ export class WalletDeploymentStarknetService accountAddress, ) + const signer = { + type: signerType, + derivationPath, + } + + const accountId = getAccountIdentifier(accountAddress, networkId, signer) + const account: CreateWalletAccount = { + id: accountId, name: defaultAccountName, network, networkId: network.id, address: accountAddress, - signer: { - type: signerType, - derivationPath, - }, + signer, type: accountType, classHash: addressSchema.parse(payload.classHash), // This is only true for new Cairo 1 accounts. For Cairo 0, this is the proxy contract class hash cairoVersion: accountType === "standardCairo0" ? "0" : "1", @@ -575,6 +558,7 @@ export class WalletDeploymentStarknetService if (accountType === "multisig" && multisigPayload) { await this.multisigStore.upsert({ + id: account.id, address: account.address, networkId: account.networkId, signers: multisigPayload.signers, @@ -586,7 +570,7 @@ export class WalletDeploymentStarknetService }) } - await this.accountSharedService.selectAccount(account) + await this.accountSharedService.selectAccount(account.id) this.ampli.accountCreated({ "account index": index, "account type": sanitizeAccountType(accountType), @@ -601,17 +585,70 @@ export class WalletDeploymentStarknetService } // TODO: check if also want it for ledger - if (signerType === "local_secret") { + if (signerType === SignerType.LOCAL_SECRET) { + const signer = await this.cryptoStarknetService.getSigner( + account.id, + account.type, + ) await this.trackReferral(account, signer) } return account } + private async buildDeploymentPayload( + walletAccount: ArgentWalletAccount, + transactionDetails?: EstimateFeeDetails | undefined, + ) { + const starknetAccount = + await this.accountStarknetService.getStarknetAccount(walletAccount.id) + + if (!starknetAccount) { + throw new AccountError({ code: "NOT_FOUND" }) + } + + if (!("deployAccount" in starknetAccount)) { + throw new AccountError({ code: "CANNOT_DEPLOY_OLD_ACCOUNTS" }) + } + + const deployAccountPayload = + await this.getAccountOrMultisigDeploymentPayload(walletAccount) + + let maxFeeOrBounds = {} + + if (transactionDetails?.maxFee || transactionDetails?.resourceBounds) { + maxFeeOrBounds = { + maxFee: transactionDetails.maxFee, + resourceBounds: transactionDetails.resourceBounds, + } + } else { + const estimatedFees = estimatedFeeToMaxResourceBounds( + await this.getAccountDeploymentFee( + walletAccount, + mapVersionToFeeToken(transactionDetails?.version ?? "0x1"), + ), + ) + maxFeeOrBounds = estimatedFees + } + + const details = { + ...transactionDetails, + ...maxFeeOrBounds, + } + + return { + account: starknetAccount, + payload: deployAccountPayload, + details, + } + } + private async trackReferral( account: WalletAccount, signer: BaseSignerInterface, ) { - const { publicKey } = await this.cryptoStarknetService.getPublicKey(account) + const { publicKey } = await this.cryptoStarknetService.getPublicKey( + account.id, + ) const hash = pedersen(keccak(stringToBytes("utf8", "referral")), publicKey) const signature = await signer.signRawMsgHash(hash) return this.referralService.trackReferral({ diff --git a/packages/extension/src/background/wallet/index.ts b/packages/extension/src/background/wallet/index.ts index c193ed280..32cf5c58b 100644 --- a/packages/extension/src/background/wallet/index.ts +++ b/packages/extension/src/background/wallet/index.ts @@ -1,13 +1,15 @@ -import { Account, InvocationsDetails } from "starknet" +import type { Account, InvocationsDetails } from "starknet" -import { Address } from "@argent/x-shared" -import { ProgressCallback } from "ethers" -import { WalletAccountSharedService } from "../../shared/account/service/accountSharedService/WalletAccountSharedService" -import { PendingMultisig } from "../../shared/multisig/types" -import { Network } from "../../shared/network" -import { BaseSignerInterface } from "../../shared/signer/BaseSignerInterface" -import { +import type { Address } from "@argent/x-shared" +import type { ProgressCallback } from "ethers" +import type { WalletAccountSharedService } from "../../shared/account/service/accountSharedService/WalletAccountSharedService" +import type { PendingMultisig } from "../../shared/multisig/types" +import type { Network } from "../../shared/network" +import type { BaseSignerInterface } from "../../shared/signer/BaseSignerInterface" +import type { + AccountId, ArgentAccountType, + ArgentWalletAccount, BaseMultisigWalletAccount, BaseWalletAccount, CreateAccountType, @@ -17,14 +19,15 @@ import { SignerType, WalletAccount, } from "../../shared/wallet.model" -import { WalletAccountStarknetService } from "./account/WalletAccountStarknetService" -import { WalletBackupService } from "./backup/WalletBackupService" -import { WalletCryptoSharedService } from "./crypto/WalletCryptoSharedService" -import { WalletCryptoStarknetService } from "./crypto/WalletCryptoStarknetService" -import { WalletDeploymentStarknetService } from "./deployment/WalletDeploymentStarknetService" -import { WalletRecoverySharedService } from "./recovery/WalletRecoverySharedService" -import { WalletRecoveryStarknetService } from "./recovery/WalletRecoveryStarknetService" -import { WalletSessionService } from "./session/WalletSessionService" +import type { WalletAccountStarknetService } from "./account/WalletAccountStarknetService" +import type { WalletBackupService } from "./backup/WalletBackupService" +import type { WalletCryptoSharedService } from "./crypto/WalletCryptoSharedService" +import type { WalletCryptoStarknetService } from "./crypto/WalletCryptoStarknetService" +import type { WalletDeploymentStarknetService } from "./deployment/WalletDeploymentStarknetService" +import type { WalletRecoverySharedService } from "./recovery/WalletRecoverySharedService" +import type { WalletRecoveryStarknetService } from "./recovery/WalletRecoveryStarknetService" +import type { WalletSessionService } from "./session/WalletSessionService" +import type { ValidatedImport } from "../../shared/accountImport/types" export class Wallet { constructor( @@ -49,21 +52,24 @@ export class Wallet { type, ) } - public async getAccount( - selector: BaseWalletAccount, - ): Promise { - return this.walletAccountSharedService.getAccount(selector) + public async getAccount(accountId: AccountId): Promise { + return this.walletAccountSharedService.getAccount(accountId) + } + public async getArgentAccount( + accountId: AccountId, + ): Promise { + return this.walletAccountSharedService.getArgentAccount(accountId) } public async getSelectedAccount(): Promise { return this.walletAccountSharedService.getSelectedAccount() } public async selectAccount( - accountIdentifier?: BaseWalletAccount | NetworkOnlyPlaceholderAccount, + accountIdentifier?: AccountId | NetworkOnlyPlaceholderAccount, ) { return this.walletAccountSharedService.selectAccount(accountIdentifier) } - public async getMultisigAccount(selector: BaseWalletAccount) { - return this.walletAccountSharedService.getMultisigAccount(selector) + public async getMultisigAccount(accountId: AccountId) { + return this.walletAccountSharedService.getMultisigAccount(accountId) } public async getLastUsedOnNetwork(networkId: string) { return this.walletAccountSharedService.getLastUsedAccountOnNetwork( @@ -72,12 +78,9 @@ export class Wallet { } // WalletAccountStarknetService - public async getStarknetAccount( - selector: BaseWalletAccount, - useLatest = false, - ) { + public async getStarknetAccount(accountId: AccountId, useLatest = false) { return this.walletAccountStarknetService.getStarknetAccount( - selector, + accountId, useLatest, ) } @@ -123,6 +126,10 @@ export class Wallet { ) } + public importAccount(account: ValidatedImport) { + return this.walletAccountStarknetService.importAccount(account) + } + // WalletBackupService public async getBackup() { return this.walletBackupService.getBackup() @@ -154,13 +161,11 @@ export class Wallet { public async getSignerForAccount(account: WalletAccount) { return this.walletCryptoStarknetService.getSignerForAccount(account) } - public async getPrivateKey( - baseWalletAccount: BaseWalletAccount, - ): Promise { - return this.walletCryptoStarknetService.getPrivateKey(baseWalletAccount) + public async getPrivateKey(accountId: AccountId): Promise { + return this.walletCryptoStarknetService.getPrivateKey(accountId) } - public async getPublicKey(baseAccount?: BaseWalletAccount) { - return this.walletCryptoStarknetService.getPublicKey(baseAccount) + public async getPublicKey(accountId?: AccountId) { + return this.walletCryptoStarknetService.getPublicKey(accountId) } public async getNextPublicKey( @@ -207,7 +212,7 @@ export class Wallet { // WalletDeploymentStarknetService public async deployAccount( - walletAccount: WalletAccount, + walletAccount: ArgentWalletAccount, transactionDetails?: InvocationsDetails | undefined, ) { return this.walletDeploymentStarknetService.deployAccount( @@ -215,8 +220,19 @@ export class Wallet { transactionDetails, ) } + + public async getDeployAccountTransactionHash( + walletAccount: ArgentWalletAccount, + transactionDetails?: InvocationsDetails | undefined, + ) { + return this.walletDeploymentStarknetService.getDeployAccountTransactionHash( + walletAccount, + transactionDetails, + ) + } + public async getAccountDeploymentFee( - walletAccount: WalletAccount, + walletAccount: ArgentWalletAccount, feeTokenAddress: Address, ) { return this.walletDeploymentStarknetService.getAccountDeploymentFee( @@ -224,21 +240,20 @@ export class Wallet { feeTokenAddress, ) } - public async redeployAccount(account: WalletAccount) { - return this.walletDeploymentStarknetService.redeployAccount(account) - } - public async getAccountDeploymentPayload(walletAccount: WalletAccount) { + public async getAccountDeploymentPayload(walletAccount: ArgentWalletAccount) { return this.walletDeploymentStarknetService.getAccountDeploymentPayload( walletAccount, ) } - public async getMultisigDeploymentPayload(walletAccount: WalletAccount) { + public async getMultisigDeploymentPayload( + walletAccount: ArgentWalletAccount, + ) { return this.walletDeploymentStarknetService.getMultisigDeploymentPayload( walletAccount, ) } public async getAccountOrMultisigDeploymentPayload( - walletAccount: WalletAccount, + walletAccount: ArgentWalletAccount, ) { return this.walletDeploymentStarknetService.getAccountOrMultisigDeploymentPayload( walletAccount, @@ -258,9 +273,8 @@ export class Wallet { public async getDeployContractPayloadForMultisig(props: { threshold: number signers: string[] - index: number networkId: string - signerType: SignerType + publicKey: string }) { return this.walletDeploymentStarknetService.getDeployContractPayloadForMultisig( props, diff --git a/packages/extension/src/background/wallet/recovery/IWalletRecoveryService.ts b/packages/extension/src/background/wallet/recovery/IWalletRecoveryService.ts index 71b7b63fe..019657bc1 100644 --- a/packages/extension/src/background/wallet/recovery/IWalletRecoveryService.ts +++ b/packages/extension/src/background/wallet/recovery/IWalletRecoveryService.ts @@ -1,5 +1,5 @@ -import { Network } from "../../../shared/network" -import { +import type { Network } from "../../../shared/network" +import type { BaseWalletAccount, RecoveredLedgerMultisig, WalletAccount, diff --git a/packages/extension/src/background/wallet/recovery/WalletRecoverySharedService.test.ts b/packages/extension/src/background/wallet/recovery/WalletRecoverySharedService.test.ts index f11f0fd9d..2a0292be4 100644 --- a/packages/extension/src/background/wallet/recovery/WalletRecoverySharedService.test.ts +++ b/packages/extension/src/background/wallet/recovery/WalletRecoverySharedService.test.ts @@ -4,25 +4,25 @@ import { generateMnemonic, mnemonicToSeedSync } from "@scure/bip39" import { wordlist } from "@scure/bip39/wordlists/english" import { grindKey } from "micro-starknet" import { encode } from "starknet" -import { Mock } from "vitest" +import type { Mock } from "vitest" import { defaultNetworks } from "../../../shared/network" -import { +import type { IObjectStore, IRepository, } from "../../../shared/storage/__new/interface" -import { WalletAccount } from "../../../shared/wallet.model" -import { WalletSession } from "../../../shared/account/service/accountSharedService/WalletAccountSharedService" +import type { WalletAccount } from "../../../shared/wallet.model" +import type { WalletSession } from "../../../shared/account/service/accountSharedService/WalletAccountSharedService" +import { WalletRecoverySharedService } from "./WalletRecoverySharedService" +import type { WalletRecoveryStarknetService } from "./WalletRecoveryStarknetService" +import { WalletError } from "../../../shared/errors/wallet" +import type { WalletStorageProps } from "../../../shared/wallet/walletStore" import { emitterMock, getSessionStoreMock, getStoreMock, getWalletStoreMock, -} from "../test.utils" -import { WalletRecoverySharedService } from "./WalletRecoverySharedService" -import { WalletRecoveryStarknetService } from "./WalletRecoveryStarknetService" -import { WalletError } from "../../../shared/errors/wallet" -import { WalletStorageProps } from "../../../shared/wallet/walletStore" +} from "../../../shared/test.utils" describe("WalletRecoverySharedService", () => { let service: WalletRecoverySharedService diff --git a/packages/extension/src/background/wallet/recovery/WalletRecoverySharedService.ts b/packages/extension/src/background/wallet/recovery/WalletRecoverySharedService.ts index 020c5fe76..e670b2831 100644 --- a/packages/extension/src/background/wallet/recovery/WalletRecoverySharedService.ts +++ b/packages/extension/src/background/wallet/recovery/WalletRecoverySharedService.ts @@ -1,18 +1,15 @@ import { defaultNetworks } from "../../../shared/network" -import { INetworkService } from "../../../shared/network/service/INetworkService" -import { +import type { INetworkService } from "../../../shared/network/service/INetworkService" +import type { IObjectStore, IRepository, } from "../../../shared/storage/__new/interface" -import { WalletAccount } from "../../../shared/wallet.model" -import { WalletSession } from "../session/walletSession.model" -import { - Events, - IWalletRecoveryService, - Recovered, -} from "./IWalletRecoveryService" +import type { WalletAccount } from "../../../shared/wallet.model" +import type { WalletSession } from "../session/walletSession.model" +import type { Events, IWalletRecoveryService } from "./IWalletRecoveryService" +import { Recovered } from "./IWalletRecoveryService" import { WalletError } from "../../../shared/errors/wallet" -import Emittery from "emittery" +import type Emittery from "emittery" import type { WalletStorageProps } from "../../../shared/wallet/walletStore" import { isEqualAddress } from "@argent/x-shared" diff --git a/packages/extension/src/background/wallet/recovery/WalletRecoveryStarknetService.ts b/packages/extension/src/background/wallet/recovery/WalletRecoveryStarknetService.ts index 87457c508..f36c6f433 100644 --- a/packages/extension/src/background/wallet/recovery/WalletRecoveryStarknetService.ts +++ b/packages/extension/src/background/wallet/recovery/WalletRecoveryStarknetService.ts @@ -1,6 +1,7 @@ import { isEmpty, partition, union } from "lodash-es" -import { RpcProvider, num } from "starknet" -import { WalletAccountSharedService } from "../../../shared/account/service/accountSharedService/WalletAccountSharedService" +import type { RpcProvider } from "starknet" +import { num } from "starknet" +import type { WalletAccountSharedService } from "../../../shared/account/service/accountSharedService/WalletAccountSharedService" import { getAccountCairoVersionFromChain, @@ -9,20 +10,20 @@ import { getAccountEscapeFromChain, getAccountGuardiansFromChain, } from "../../../shared/account/details" -import { - DetailFetchers, - getAndMergeAccountDetails, -} from "../../../shared/account/details/getAndMergeAccountDetails" -import { Network, getProvider } from "../../../shared/network" -import { +import type { DetailFetchers } from "../../../shared/account/details/getAndMergeAccountDetails" +import { getAndMergeAccountDetails } from "../../../shared/account/details/getAndMergeAccountDetails" +import type { Network } from "../../../shared/network" +import { getProvider } from "../../../shared/network" +import type { + ArgentWalletAccount, BaseMultisigWalletAccount, RecoveredLedgerMultisig, - SignerType, WalletAccount, } from "../../../shared/wallet.model" +import { SignerType } from "../../../shared/wallet.model" +import type { Address } from "@argent/x-shared" import { - Address, addressSchema, ensureArray, isContractDeployed, @@ -40,12 +41,12 @@ import { argentXHeaders, } from "../../../shared/api/headers" import { RecoveryError } from "../../../shared/errors/recovery" -import { ILedgerSharedService } from "../../../shared/ledger/service/ILedgerSharedService" -import { ApiMultisigDataForSigner } from "../../../shared/multisig/multisig.model" -import { IMultisigBackendService } from "../../../shared/multisig/service/backend/IMultisigBackendService" +import type { ILedgerSharedService } from "../../../shared/ledger/service/ILedgerSharedService" +import type { ApiMultisigDataForSigner } from "../../../shared/multisig/multisig.model" +import type { IMultisigBackendService } from "../../../shared/multisig/service/backend/IMultisigBackendService" import { getDefaultNetworkId } from "../../../shared/network/utils" import { ArgentSigner } from "../../../shared/signer" -import { PublicKeyWithIndex } from "../../../shared/signer/types" +import type { PublicKeyWithIndex } from "../../../shared/signer/types" import { getBaseDerivationPath } from "../../../shared/signer/utils" import { getStandardAccountDiscoveryUrl } from "../../../shared/utils/getStandardAccountDiscoveryUrl" import { @@ -56,14 +57,16 @@ import { sortAccountsByDerivationPath, sortMultisigByDerivationPath, } from "../../../shared/utils/accountsMultisigSort" -import { IRepository } from "../../../shared/storage/__new/interface" -import { WalletCryptoStarknetService } from "../crypto/WalletCryptoStarknetService" -import { IWalletRecoveryService } from "./IWalletRecoveryService" +import type { IRepository } from "../../../shared/storage/__new/interface" +import type { WalletCryptoStarknetService } from "../crypto/WalletCryptoStarknetService" +import type { IWalletRecoveryService } from "./IWalletRecoveryService" +import { getPathForIndex } from "../../../shared/utils/derivationPath" +import { getAccountIdentifier } from "../../../shared/utils/accountIdentifier" const INITIAL_PUB_KEY_COUNT = 20 interface TempAccountData { - account: WalletAccount + account: ArgentWalletAccount pubKeyWithIndex: PublicKeyWithIndex } @@ -90,7 +93,7 @@ export class WalletRecoveryStarknetService implements IWalletRecoveryService { network: Network, initialPubKeyCount: number = INITIAL_PUB_KEY_COUNT, ): Promise { - const accounts: WalletAccount[] = [] + const accounts: ArgentWalletAccount[] = [] let pubKeyCount = initialPubKeyCount let lastCheck = 0 @@ -274,7 +277,7 @@ export class WalletRecoveryStarknetService implements IWalletRecoveryService { network, needsDeploy: false, name: `Account ${pubKeyWithIndex.index + 1}`, - address: "", + address: "0x0", // we don't know the address yet }), owner: pubKeyWithIndex.pubKey, } @@ -322,7 +325,7 @@ export class WalletRecoveryStarknetService implements IWalletRecoveryService { } private async fetchValidAccounts( - tempAccounts: WalletAccount[], + tempAccounts: ArgentWalletAccount[], network: Network, ) { const tempAddresses = tempAccounts.map((account) => @@ -346,7 +349,7 @@ export class WalletRecoveryStarknetService implements IWalletRecoveryService { try { await provider.getSpecVersion() return true - } catch (e) { + } catch { return false } } @@ -443,7 +446,7 @@ export class WalletRecoveryStarknetService implements IWalletRecoveryService { network, publicKeysWithIndices.map(({ pubKey }) => pubKey), ) - } catch (error) { + } catch { return [] } @@ -453,7 +456,7 @@ export class WalletRecoveryStarknetService implements IWalletRecoveryService { network, signerType, ) - return resolvedMultisigs.filter((m): m is WalletAccount => !!m) + return resolvedMultisigs.filter((m): m is ArgentWalletAccount => !!m) } private async resolveValidMultisigs( @@ -463,24 +466,36 @@ export class WalletRecoveryStarknetService implements IWalletRecoveryService { signerType: SignerType, ) { const multisigToInsert: BaseMultisigWalletAccount[] = [] - const resolvedMultisigs = validMultisigs.content - .map((validMultisig) => { - const pubKeyWithIndex = publicKeysWithIndices.find(({ pubKey }) => + const resolvedMultisigs: ArgentWalletAccount[] = [] + + for (const validMultisig of validMultisigs.content) { + const matchingPubKeysWithIndices = publicKeysWithIndices.filter( + ({ pubKey }) => validMultisig.signers.some( (signer) => num.toBigInt(signer) === num.toBigInt(pubKey), ), - ) - if (!pubKeyWithIndex) { - return + ) + + for (const pubKeyWithIndex of matchingPubKeysWithIndices) { + const signer = { + type: signerType, + derivationPath: getPathForIndex( + pubKeyWithIndex.index, + getBaseDerivationPath("multisig", signerType), + ), } - multisigToInsert.push({ + + const multisigData: BaseMultisigWalletAccount = { ...validMultisig, publicKey: pubKeyWithIndex.pubKey, networkId: network.id, updatedAt: Date.now(), - }) + id: getAccountIdentifier(validMultisig.address, network.id, signer), + } - return { + multisigToInsert.push(multisigData) + + resolvedMultisigs.push({ ...this.walletAccountSharedService.getDefaultMultisigAccount({ index: pubKeyWithIndex.index, address: validMultisig.address, @@ -489,21 +504,21 @@ export class WalletRecoveryStarknetService implements IWalletRecoveryService { signerType, }), type: "multisig", - } - }) - .filter((m): m is WalletAccount => !!m) + }) + } + } + + const multisigAccountsWithDetails = await getAndMergeAccountDetails( + resolvedMultisigs, + [getAccountClassHashFromChain], + ) - const multisigAccountsWithDetails = ( - await getAndMergeAccountDetails(resolvedMultisigs, [ - getAccountClassHashFromChain, - ]) - ).sort(sortMultisigByDerivationPath) await this.multisigStore.upsert(multisigToInsert) - return multisigAccountsWithDetails - } + return multisigAccountsWithDetails.sort(sortMultisigByDerivationPath) + } private async fetchValidSmartAccounts( - tempAccounts: WalletAccount[], + tempAccounts: ArgentWalletAccount[], network: Network, ) { const pukKeysAddresses = tempAccounts @@ -523,7 +538,7 @@ export class WalletRecoveryStarknetService implements IWalletRecoveryService { return [] } - return tempAccounts.reduce((acc: WalletAccount[], account) => { + return tempAccounts.reduce((acc: ArgentWalletAccount[], account) => { const validAccount = validAccountsResponse.find((a) => { return account.owner && isEqualAddress(account.owner, a.ownerAddress) }) @@ -534,6 +549,11 @@ export class WalletRecoveryStarknetService implements IWalletRecoveryService { const accountWithDetails = { ...account, address: validAccount.account, + id: getAccountIdentifier( + validAccount.account, + network.id, + account.signer, + ), guardian: hasGuardian ? validAccount.guardianAddresses?.[0] : undefined, @@ -566,7 +586,7 @@ export class WalletRecoveryStarknetService implements IWalletRecoveryService { }, []) } - private async getAccountDetails(accounts: WalletAccount[]) { + private async getAccountDetails(accounts: ArgentWalletAccount[]) { try { const standardAccountDetailFetchers: DetailFetchers[] = [ getAccountDeployStatusFromChain, diff --git a/packages/extension/src/background/wallet/session/WalletSessionService.ts b/packages/extension/src/background/wallet/session/WalletSessionService.ts index 4f45fcc02..c46f08bcd 100644 --- a/packages/extension/src/background/wallet/session/WalletSessionService.ts +++ b/packages/extension/src/background/wallet/session/WalletSessionService.ts @@ -1,21 +1,18 @@ -import Emittery from "emittery" -import { - ProgressCallback, - Wallet, - decryptKeystoreJson, - encryptKeystoreJson, -} from "ethers" +import type Emittery from "emittery" +import type { ProgressCallback } from "ethers" +import { Wallet, decryptKeystoreJson, encryptKeystoreJson } from "ethers" import { noop, throttle } from "lodash-es" import { SessionError } from "../../../shared/errors/session" -import { IObjectStore } from "../../../shared/storage/__new/interface" -import { +import type { IObjectStore } from "../../../shared/storage/__new/interface" +import type { WalletBackupService, WalletStorageProps, } from "../backup/WalletBackupService" -import { WalletRecoverySharedService } from "../recovery/WalletRecoverySharedService" +import type { WalletRecoverySharedService } from "../recovery/WalletRecoverySharedService" import { walletToKeystore } from "../utils" -import { Events, Locked } from "./interface" +import type { Events } from "./interface" +import { Locked } from "./interface" export interface WalletSession { secret: string diff --git a/packages/extension/src/background/wallet/test.utils.ts b/packages/extension/src/background/wallet/test.utils.ts index 67bf96768..4723390b7 100644 --- a/packages/extension/src/background/wallet/test.utils.ts +++ b/packages/extension/src/background/wallet/test.utils.ts @@ -1,17 +1,26 @@ -import { PendingMultisig } from "../../shared/multisig/types" -import { - MockFnObjectStore, - MockFnRepository, -} from "../../shared/storage/__new/__test__/mockFunctionImplementation" -import { IObjectStore } from "../../shared/storage/__new/interface" -import { - BaseMultisigWalletAccount, - WalletAccount, -} from "../../shared/wallet.model" +import { Wallet } from "." +import { AccountImportSharedService } from "../../shared/accountImport/service/AccountImportSharedService" +import { AnalyticsService } from "../../shared/analytics/AnalyticsService" +import type { ISettingsStorage } from "../../shared/settings/types" +import type { KeyValueStorage } from "../../shared/storage" import { - WalletAccountSharedService, - WalletSession, -} from "../../shared/account/service/accountSharedService/WalletAccountSharedService" + accountServiceMock, + accountSharedServiceMock, + emitterMock, + getKeyValueStorage, + getMultisigStoreMock, + getPendingMultisigStoreMock, + getSessionStoreMock, + getStoreMock, + getWalletStoreMock, + ledgerServiceMock, + loadContractsMock, + multisigBackendServiceMock, + networkServiceMock, + pkManagerMock, + SCRYPT_N_TEST, +} from "../../shared/test.utils" +import type { IReferralService } from "../services/referral/IReferralService" import { WalletAccountStarknetService } from "./account/WalletAccountStarknetService" import { WalletBackupService } from "./backup/WalletBackupService" import { WalletCryptoSharedService } from "./crypto/WalletCryptoSharedService" @@ -20,146 +29,6 @@ import { WalletDeploymentStarknetService } from "./deployment/WalletDeploymentSt import { WalletRecoverySharedService } from "./recovery/WalletRecoverySharedService" import { WalletRecoveryStarknetService } from "./recovery/WalletRecoveryStarknetService" import { WalletSessionService } from "./session/WalletSessionService" -import { Wallet } from "." -import { MultisigBackendService } from "../../shared/multisig/service/backend/MultisigBackendService" -import { WalletStorageProps } from "../../shared/wallet/walletStore" -import { AnalyticsService } from "../../shared/analytics/AnalyticsService" -import { IReferralService } from "../services/referral/IReferralService" -import { KeyValueStorage } from "../../shared/storage" -import { ISettingsStorage } from "../../shared/settings/types" -import { IHttpService } from "@argent/x-shared" -import { LedgerSharedService } from "../../shared/ledger/service/LedgerSharedService" -import { StarknetChainService } from "../../shared/chain/service/StarknetChainService" -import { AccountService } from "../../shared/account/service/accountService/AccountService" - -const isDev = true -const isTest = true -const isDevOrTest = isDev || isTest -const SCRYPT_N = isDevOrTest ? 64 : 262144 -const defaultKeyValueStorage = { - get: vi.fn(), - set: vi.fn(), - delete: vi.fn(), - subscribe: vi.fn(), - namespace: "", - areaName: "local", - defaults: {}, -} - -export const httpServiceMock = { - post: vi.fn(), - put: vi.fn(), - get: vi.fn(), - delete: vi.fn(), -} as IHttpService - -// Replace with class store after migration -const defaultArrayStorage = new MockFnRepository() - -const defaultObjectStorage = new MockFnObjectStore() - -const getKeyValueStorage = >( - overrides?: Partial>, -): IObjectStore => { - return { - ...defaultKeyValueStorage, - ...overrides, - } as IObjectStore -} - -const getArrayStorage = ( - overrides?: Partial>, -): MockFnRepository => { - return { - ...defaultArrayStorage, - ...overrides, - } as MockFnRepository -} - -const getObjectStorage = ( - overrides?: Partial>, -): IObjectStore => { - return { - ...defaultObjectStorage, - ...overrides, - } as IObjectStore -} - -export const getStoreMock = (overrides?: IObjectStore) => - getKeyValueStorage(overrides) - -export const getWalletStoreMock = ( - overrides?: Partial>, -) => getArrayStorage(overrides) - -export const getSessionStoreMock = ( - overrides?: Partial>, -) => getObjectStorage(overrides) - -export const getMultisigStoreMock = ( - overrides?: Partial>, -) => getArrayStorage(overrides) - -export const getPendingMultisigStoreMock = ( - overrides?: Partial>, -) => getArrayStorage(overrides) - -export const getAccountStoreMock = ( - overrides?: Partial>, -) => getArrayStorage(overrides) - -export const loadContractsMock = vi.fn() -export const networkServiceMock = { - getById: vi.fn(), -} - -export const backupServiceMock = new WalletBackupService( - getStoreMock(), - getWalletStoreMock(), - networkServiceMock, -) - -export const emitterMock = { - anyEvent: vi.fn(), - bindMethods: vi.fn(), - clearListeners: vi.fn(), - debug: vi.fn(), - emit: vi.fn(), - emitSerial: vi.fn(), - events: vi.fn(), - listenerCount: vi.fn(), - off: vi.fn(), - offAny: vi.fn(), - on: vi.fn(), - onAny: vi.fn(), - once: vi.fn(), -} - -export const multisigBackendServiceMock = new MultisigBackendService( - "mockBackendUrl", -) - -const chainServiceMock = new StarknetChainService(networkServiceMock) - -export const accountServiceMock = new AccountService( - chainServiceMock, - getAccountStoreMock(), -) - -export const accountSharedServiceMock = new WalletAccountSharedService( - getStoreMock(), - getWalletStoreMock(), - getSessionStoreMock(), - getMultisigStoreMock(), - getPendingMultisigStoreMock(), - httpServiceMock, - accountServiceMock, -) - -export const ledgerServiceMock = new LedgerSharedService( - networkServiceMock, - multisigBackendServiceMock, -) export const cryptoStarknetServiceMock = new WalletCryptoStarknetService( getWalletStoreMock(), @@ -173,6 +42,7 @@ export const cryptoStarknetServiceMock = new WalletCryptoStarknetService( getPendingMultisigStoreMock(), accountSharedServiceMock, ledgerServiceMock, + pkManagerMock, loadContractsMock, ) @@ -193,13 +63,25 @@ export const recoverySharedServiceMock = new WalletRecoverySharedService( recoveryStarknetServiceMock, ) +export const backupServiceMock = new WalletBackupService( + getStoreMock(), + getWalletStoreMock(), + networkServiceMock, +) + export const sessionServiceMock = new WalletSessionService( emitterMock, getStoreMock(), getSessionStoreMock(), backupServiceMock, recoverySharedServiceMock, - SCRYPT_N, + SCRYPT_N_TEST, +) + +export const importAccountServiceMock = new AccountImportSharedService( + accountServiceMock, + networkServiceMock, + pkManagerMock, ) export const accountStarknetServiceMock = new WalletAccountStarknetService( @@ -210,6 +92,7 @@ export const accountStarknetServiceMock = new WalletAccountStarknetService( cryptoStarknetServiceMock, multisigBackendServiceMock, ledgerServiceMock, + importAccountServiceMock, ) export const getDefaultReferralService = (): IReferralService => { return { trackReferral: () => Promise.resolve() } @@ -223,7 +106,6 @@ export const analyticsServiceMock = new AnalyticsService( export const deployStarknetServiceMock = new WalletDeploymentStarknetService( getWalletStoreMock(), getMultisigStoreMock(), - getPendingMultisigStoreMock(), sessionServiceMock, getSessionStoreMock(), accountSharedServiceMock, @@ -241,7 +123,7 @@ export const cryptoSharedServiceMock = new WalletCryptoSharedService( backupServiceMock, recoverySharedServiceMock, deployStarknetServiceMock, - SCRYPT_N, + SCRYPT_N_TEST, ) export const walletSingletonMock = new Wallet( diff --git a/packages/extension/src/background/wallet/utils.ts b/packages/extension/src/background/wallet/utils.ts index 768a6342f..08fb5b99f 100644 --- a/packages/extension/src/background/wallet/utils.ts +++ b/packages/extension/src/background/wallet/utils.ts @@ -1,4 +1,4 @@ -import { HDNodeWallet, KeystoreAccount } from "ethers" +import type { HDNodeWallet, KeystoreAccount } from "ethers" export function walletToKeystore(wallet: HDNodeWallet): KeystoreAccount { const account: KeystoreAccount = { diff --git a/packages/extension/src/background/walletSingleton.ts b/packages/extension/src/background/walletSingleton.ts index 2e96231f8..dc7c7f599 100644 --- a/packages/extension/src/background/walletSingleton.ts +++ b/packages/extension/src/background/walletSingleton.ts @@ -16,16 +16,18 @@ import { WalletDeploymentStarknetService } from "./wallet/deployment/WalletDeplo import { loadContracts } from "./wallet/loadContracts" import { WalletRecoverySharedService } from "./wallet/recovery/WalletRecoverySharedService" import { WalletRecoveryStarknetService } from "./wallet/recovery/WalletRecoveryStarknetService" -import { Events as SessionEvents } from "./wallet/session/interface" +import type { Events as SessionEvents } from "./wallet/session/interface" import { WalletSessionService } from "./wallet/session/WalletSessionService" import { MultisigBackendService } from "../shared/multisig/service/backend/MultisigBackendService" import { ARGENT_MULTISIG_URL } from "../shared/api/constants" -import { Events as RecoverySharedEvents } from "./wallet/recovery/IWalletRecoveryService" +import type { Events as RecoverySharedEvents } from "./wallet/recovery/IWalletRecoveryService" import { accountSharedService } from "../shared/account/service" import { sessionRepo } from "../shared/account/store/session" import { ampli } from "../shared/analytics" import { referralService } from "./services/referral" import { ledgerSharedService } from "../shared/ledger/service" +import { accountImportSharedService } from "../shared/accountImport/service" +import { pkManager } from "../shared/accountImport/pkManager" const isDev = process.env.NODE_ENV === "development" const isTest = process.env.NODE_ENV === "test" @@ -52,6 +54,7 @@ export const cryptoStarknetService = new WalletCryptoStarknetService( pendingMultisigRepo, accountSharedService, ledgerSharedService, + pkManager, loadContracts, ) @@ -81,7 +84,7 @@ export const sessionService = new WalletSessionService( SCRYPT_N, ) -const accountStarknetService = new WalletAccountStarknetService( +export const accountStarknetService = new WalletAccountStarknetService( pendingMultisigRepo, networkService, sessionService, @@ -89,12 +92,12 @@ const accountStarknetService = new WalletAccountStarknetService( cryptoStarknetService, multisigBackendService, ledgerSharedService, + accountImportSharedService, ) const deployStarknetService = new WalletDeploymentStarknetService( accountRepo, multisigBaseWalletRepo, - pendingMultisigRepo, sessionService, sessionRepo, accountSharedService, diff --git a/packages/extension/src/background/workers.ts b/packages/extension/src/background/workers.ts index 5f533220f..d0590fe35 100644 --- a/packages/extension/src/background/workers.ts +++ b/packages/extension/src/background/workers.ts @@ -12,6 +12,9 @@ import { scheduleWorker } from "./services/schedule/worker" import { analyticsWorker } from "./services/analytics" import { activityCacheWorker } from "./services/activity/cache/worker" import { activityWorker } from "./services/activity" +import { nonceManagementWorker } from "./nonceManagement/worker" +import { devWorker } from "./services/dev" +import { investmentWorker } from "./services/investments/worker" /** TODO: refactor: remove this facade */ export function initWorkers() { @@ -20,6 +23,7 @@ export function initWorkers() { accountWorker, tokenWorker, nftsWorker, + investmentWorker, multisigWorker, networkWorker, knownDappsWorker, @@ -30,5 +34,7 @@ export function initWorkers() { analyticsWorker, activityCacheWorker, activityWorker, + nonceManagementWorker, + devWorker, } } diff --git a/packages/extension/src/inpage/ArgentXAccount.ts b/packages/extension/src/inpage/ArgentXAccount.ts index e0f0fcfcc..f3cb03068 100644 --- a/packages/extension/src/inpage/ArgentXAccount.ts +++ b/packages/extension/src/inpage/ArgentXAccount.ts @@ -1,6 +1,5 @@ -import { +import type { Abi, - Account, Call, DeclareContractPayload, DeclareContractResponse, @@ -8,14 +7,13 @@ import { ProviderInterface, Signature, UniversalDetails, - defaultProvider, - ec, } from "starknet" +import { Account, defaultProvider, ec } from "starknet" import { sendMessage, waitForMessage } from "./messageActions" -import { StarknetMethodArgumentsSchemas } from "starknetkit/window" -import { SignMessageOptions } from "../shared/messages/ActionMessage" -import { TypedData } from "@starknet-io/types-js" +import { StarknetMethodArgumentsSchemas } from "@argent/x-window" +import type { SignMessageOptions } from "../shared/messages/ActionMessage" +import type { TypedData } from "@starknet-io/types-js" import { signTypedDataHandler } from "./requestMessageHandlers/signTypedData" /** diff --git a/packages/extension/src/inpage/ArgentXAccount4.ts b/packages/extension/src/inpage/ArgentXAccount4.ts index 81293ffdd..24f46331d 100644 --- a/packages/extension/src/inpage/ArgentXAccount4.ts +++ b/packages/extension/src/inpage/ArgentXAccount4.ts @@ -1,19 +1,17 @@ -import { +import type { Abi, Call, InvocationsDetails, ProviderInterface, Signature, - defaultProvider, - ec, - Account, AccountInterface, } from "starknet4" +import { defaultProvider, ec, Account } from "starknet4" import { sendMessage, waitForMessage } from "./messageActions" -import { TypedData } from "starknet" -import { StarknetMethodArgumentsSchemas } from "starknetkit/window" -import { SignMessageOptions } from "../shared/messages/ActionMessage" +import type { TypedData } from "starknet" +import { StarknetMethodArgumentsSchemas } from "@argent/x-window" +import type { SignMessageOptions } from "../shared/messages/ActionMessage" import { signTypedDataHandler } from "./requestMessageHandlers/signTypedData" /** diff --git a/packages/extension/src/inpage/ArgentXAccount5.ts b/packages/extension/src/inpage/ArgentXAccount5.ts index 9ac289179..0dede6953 100644 --- a/packages/extension/src/inpage/ArgentXAccount5.ts +++ b/packages/extension/src/inpage/ArgentXAccount5.ts @@ -1,6 +1,5 @@ -import { +import type { Abi, - Account, AccountInterface, Call, DeclareContractPayload, @@ -8,14 +7,13 @@ import { InvocationsDetails, ProviderInterface, Signature, - defaultProvider, - ec, } from "starknet5" +import { Account, defaultProvider, ec } from "starknet5" import { sendMessage, waitForMessage } from "./messageActions" -import { StarknetMethodArgumentsSchemas } from "starknetkit/window" -import { SignMessageOptions } from "../shared/messages/ActionMessage" -import { TypedData } from "@starknet-io/types-js" +import { StarknetMethodArgumentsSchemas } from "@argent/x-window" +import type { SignMessageOptions } from "../shared/messages/ActionMessage" +import type { TypedData } from "@starknet-io/types-js" import { signTypedDataHandler } from "./requestMessageHandlers/signTypedData" /** diff --git a/packages/extension/src/inpage/ArgentXProvider.ts b/packages/extension/src/inpage/ArgentXProvider.ts index b2e77b8e3..a7c482a8c 100644 --- a/packages/extension/src/inpage/ArgentXProvider.ts +++ b/packages/extension/src/inpage/ArgentXProvider.ts @@ -1,7 +1,9 @@ import { getChainIdFromNetworkId } from "@argent/x-shared" -import { BlockIdentifier, Call, Provider, ProviderInterface } from "starknet" +import type { BlockIdentifier, Call, ProviderInterface } from "starknet" +import { Provider } from "starknet" -import { Network, getProvider } from "../shared/network" +import type { Network } from "../shared/network" +import { getProvider } from "../shared/network" import { FallbackRpcProvider } from "../shared/network/FallbackRpcProvider" import { getPublicRPCNodeUrls, isArgentNetwork } from "../shared/network/utils" diff --git a/packages/extension/src/inpage/ArgentXProvider4.ts b/packages/extension/src/inpage/ArgentXProvider4.ts index 8f877a602..e75f22344 100644 --- a/packages/extension/src/inpage/ArgentXProvider4.ts +++ b/packages/extension/src/inpage/ArgentXProvider4.ts @@ -1,7 +1,8 @@ -import { Call, Provider, ProviderInterface } from "starknet4" -import { Network } from "../shared/network" +import type { Call, ProviderInterface } from "starknet4" +import { Provider } from "starknet4" +import type { Network } from "../shared/network" import { getRandomPublicRPCNode } from "../shared/network/utils" -import { getProviderv4 } from "../shared/network/provider" +import { getProviderv4 } from "./provider" import { argentApiNetworkForNetwork } from "../shared/api/headers" export class ArgentXProviderV4 extends Provider implements ProviderInterface { diff --git a/packages/extension/src/inpage/ArgentXProvider5.ts b/packages/extension/src/inpage/ArgentXProvider5.ts index 0d9e71191..dbf16510c 100644 --- a/packages/extension/src/inpage/ArgentXProvider5.ts +++ b/packages/extension/src/inpage/ArgentXProvider5.ts @@ -1,9 +1,11 @@ import { getChainIdFromNetworkId } from "@argent/x-shared" -import { BlockIdentifier, Call, Provider, ProviderInterface } from "starknet5" +import type { BlockIdentifier, Call, ProviderInterface } from "starknet5" +import { Provider } from "starknet5" -import { Network, getProvider5 } from "../shared/network" +import type { Network } from "../shared/network" import { getPublicRPCNodeUrls, isArgentNetwork } from "../shared/network/utils" import { FallbackRpcProvider5 } from "../shared/network/FallbackRpcProvider5" +import { getProvider5 } from "./provider" export class ArgentXProvider5 extends Provider implements ProviderInterface { constructor(network: Network) { diff --git a/packages/extension/src/inpage/index.ts b/packages/extension/src/inpage/index.ts index a4111e062..44865d9b2 100644 --- a/packages/extension/src/inpage/index.ts +++ b/packages/extension/src/inpage/index.ts @@ -7,8 +7,8 @@ import { starknetWindowObject, userEventHandlers } from "./starknetWindowObject" import { shortString } from "starknet" import { isArgentNetwork } from "../shared/network/utils" import { inpageMessageClient } from "./trpcClient" -import { WalletAccount } from "../shared/wallet.model" -import { BackwardsCompatibleStarknetWindowObject } from "starknetkit/window" +import type { WalletAccount } from "../shared/wallet.model" +import type { BackwardsCompatibleStarknetWindowObject } from "@argent/x-window" const INJECT_NAMES = ["starknet", "starknet_argentX"] @@ -17,7 +17,7 @@ function attach() { // we need 2 different try catch blocks because we want to execute both even if one of them fails try { delete (window as any)[name] - } catch (e) { + } catch { // ignore } try { diff --git a/packages/extension/src/inpage/messaging.ts b/packages/extension/src/inpage/messaging.ts index f3989d279..8010912a0 100644 --- a/packages/extension/src/inpage/messaging.ts +++ b/packages/extension/src/inpage/messaging.ts @@ -11,7 +11,7 @@ export const getIsPreauthorized = async () => { 10 * 1000, // 10 seconds, temporary ) return isPreauthorized - } catch (e) { + } catch { // ignore timeout or other error } return false diff --git a/packages/extension/src/inpage/provider.ts b/packages/extension/src/inpage/provider.ts new file mode 100644 index 000000000..4f0b21fa6 --- /dev/null +++ b/packages/extension/src/inpage/provider.ts @@ -0,0 +1,42 @@ +import memoize from "memoizee" +import { RpcProvider as RpcProvider5 } from "starknet5" +import type { constants } from "starknet" +import { shortString } from "starknet" +import { RpcProvider as RpcProviderV4 } from "starknet4" + +import type { Network } from "../shared/network/type" +import { argentXHeaders } from "../shared/api/headers" + +export const getProviderForRpcUrl5 = memoize( + (rpcUrl: string, chainId?: constants.StarknetChainId): RpcProvider5 => { + return new RpcProvider5({ + nodeUrl: rpcUrl, + chainId, + headers: argentXHeaders, + }) + }, + { normalizer: ([rpcUrl, chainId]) => `${rpcUrl}::${chainId}` }, +) + +/** + * Returns a provider for the given network + * @param network + * @returns + */ +export function getProvider5(network: Network): RpcProvider5 { + const chainId = shortString.encodeShortString( + network.chainId, + ) as constants.StarknetChainId + return getProviderForRpcUrl5(network.rpcUrl, chainId) +} + +/** ======================================================================== */ + +export function getProviderv4(network: Network): RpcProviderV4 { + return new RpcProviderV4({ + nodeUrl: network.rpcUrl, + headers: argentXHeaders, + }) +} + +/** ======================================================================== */ diff --git a/packages/extension/src/inpage/requestMessageHandlers/addDeclareTransaction.ts b/packages/extension/src/inpage/requestMessageHandlers/addDeclareTransaction.ts index dc2c05fa8..bf0766974 100644 --- a/packages/extension/src/inpage/requestMessageHandlers/addDeclareTransaction.ts +++ b/packages/extension/src/inpage/requestMessageHandlers/addDeclareTransaction.ts @@ -1,4 +1,4 @@ -import { +import type { AddDeclareTransactionParameters, AddDeclareTransactionResult, } from "@starknet-io/types-js" diff --git a/packages/extension/src/inpage/requestMessageHandlers/addStarknetChain.ts b/packages/extension/src/inpage/requestMessageHandlers/addStarknetChain.ts index e33cbe8fa..d687b6ac8 100644 --- a/packages/extension/src/inpage/requestMessageHandlers/addStarknetChain.ts +++ b/packages/extension/src/inpage/requestMessageHandlers/addStarknetChain.ts @@ -1,6 +1,6 @@ -import { Address } from "@argent/x-shared" -import { AddStarknetChainParameters } from "@starknet-io/types-js" -import { AddStarknetChainParametersSchema } from "starknetkit/window" +import type { Address } from "@argent/x-shared" +import type { AddStarknetChainParameters } from "@starknet-io/types-js" +import { AddStarknetChainParametersSchema } from "@argent/x-window" import { ETH_TOKEN_ADDRESS } from "../../shared/network/constants" import { sendMessage, waitForMessage } from "../messageActions" import { WalletRPCError, WalletRPCErrorCodes } from "./errors" diff --git a/packages/extension/src/inpage/requestMessageHandlers/deploymentData.ts b/packages/extension/src/inpage/requestMessageHandlers/deploymentData.ts index a87a86fa1..31bdf07c9 100644 --- a/packages/extension/src/inpage/requestMessageHandlers/deploymentData.ts +++ b/packages/extension/src/inpage/requestMessageHandlers/deploymentData.ts @@ -1,4 +1,4 @@ -import { AccountDeploymentData } from "@starknet-io/types-js" +import type { AccountDeploymentData } from "@starknet-io/types-js" import { inpageMessageClient } from "../trpcClient" const toHex = (x: bigint) => `0x${x.toString(16)}` diff --git a/packages/extension/src/inpage/requestMessageHandlers/errors.ts b/packages/extension/src/inpage/requestMessageHandlers/errors.ts index 75969a262..5c4e1ee69 100644 --- a/packages/extension/src/inpage/requestMessageHandlers/errors.ts +++ b/packages/extension/src/inpage/requestMessageHandlers/errors.ts @@ -1,4 +1,5 @@ -import { BaseError, BaseErrorPayload } from "@argent/x-shared" +import type { BaseErrorPayload } from "@argent/x-shared" +import { BaseError } from "@argent/x-shared" export enum WalletRPCErrorCodes { UserAborted = 113, diff --git a/packages/extension/src/inpage/requestMessageHandlers/invokeTransaction.ts b/packages/extension/src/inpage/requestMessageHandlers/invokeTransaction.ts index 271464430..2b2408a8b 100644 --- a/packages/extension/src/inpage/requestMessageHandlers/invokeTransaction.ts +++ b/packages/extension/src/inpage/requestMessageHandlers/invokeTransaction.ts @@ -1,8 +1,8 @@ -import { +import type { AddInvokeTransactionParameters, AddInvokeTransactionResult, } from "@starknet-io/types-js" -import { RpcCallsArraySchema } from "starknetkit/window" +import { RpcCallsArraySchema } from "@argent/x-window" import { sendMessage, waitForMessage } from "../messageActions" import { WalletRPCError, WalletRPCErrorCodes } from "./errors" diff --git a/packages/extension/src/inpage/requestMessageHandlers/requestAccounts.ts b/packages/extension/src/inpage/requestMessageHandlers/requestAccounts.ts index 5a70a7600..4b29c9768 100644 --- a/packages/extension/src/inpage/requestMessageHandlers/requestAccounts.ts +++ b/packages/extension/src/inpage/requestMessageHandlers/requestAccounts.ts @@ -1,4 +1,4 @@ -import { RequestAccountsParameters } from "@starknet-io/types-js" +import type { RequestAccountsParameters } from "@starknet-io/types-js" import { sendMessage, waitForMessage } from "../messageActions" export async function requestAccountsHandler( diff --git a/packages/extension/src/inpage/requestMessageHandlers/signTypedData.ts b/packages/extension/src/inpage/requestMessageHandlers/signTypedData.ts index 6bec79f2e..c0637f38b 100644 --- a/packages/extension/src/inpage/requestMessageHandlers/signTypedData.ts +++ b/packages/extension/src/inpage/requestMessageHandlers/signTypedData.ts @@ -1,4 +1,4 @@ -import { TypedData } from "@starknet-io/types-js" +import type { TypedData } from "@starknet-io/types-js" import { sendMessage, waitForMessage } from "../messageActions" import { inpageMessageClient } from "../trpcClient" import { WalletRPCError, WalletRPCErrorCodes } from "./errors" diff --git a/packages/extension/src/inpage/requestMessageHandlers/switchStarknetChain.ts b/packages/extension/src/inpage/requestMessageHandlers/switchStarknetChain.ts index 02a13c103..ee5b94c19 100644 --- a/packages/extension/src/inpage/requestMessageHandlers/switchStarknetChain.ts +++ b/packages/extension/src/inpage/requestMessageHandlers/switchStarknetChain.ts @@ -1,4 +1,4 @@ -import { SwitchStarknetChainParameters } from "@starknet-io/types-js" +import type { SwitchStarknetChainParameters } from "@starknet-io/types-js" import { sendMessage, waitForMessage } from "../messageActions" import { WalletRPCError, WalletRPCErrorCodes } from "./errors" diff --git a/packages/extension/src/inpage/requestMessageHandlers/watchAsset.ts b/packages/extension/src/inpage/requestMessageHandlers/watchAsset.ts index fcaa40b61..470a40184 100644 --- a/packages/extension/src/inpage/requestMessageHandlers/watchAsset.ts +++ b/packages/extension/src/inpage/requestMessageHandlers/watchAsset.ts @@ -1,4 +1,4 @@ -import { WatchAssetParameters } from "@starknet-io/types-js" +import type { WatchAssetParameters } from "@starknet-io/types-js" import { sendMessage, waitForMessage } from "../messageActions" import { addressSchema } from "@argent/x-shared" import { WalletRPCError, WalletRPCErrorCodes } from "./errors" diff --git a/packages/extension/src/inpage/starknetWindowObject.ts b/packages/extension/src/inpage/starknetWindowObject.ts index e6f801fc4..182466552 100644 --- a/packages/extension/src/inpage/starknetWindowObject.ts +++ b/packages/extension/src/inpage/starknetWindowObject.ts @@ -3,7 +3,7 @@ import type { NetworkChangeEventHandler, WalletEvents, } from "@starknet-io/types-js" -import type { BackwardsCompatibleStarknetWindowObject } from "starknetkit/window" +import type { BackwardsCompatibleStarknetWindowObject } from "@argent/x-window" import { assertNever } from "../shared/utils/assertNever" import { sendMessage, waitForMessage } from "./messageActions" diff --git a/packages/extension/src/inpage/trpcClient.ts b/packages/extension/src/inpage/trpcClient.ts index f308a9044..1378f9193 100644 --- a/packages/extension/src/inpage/trpcClient.ts +++ b/packages/extension/src/inpage/trpcClient.ts @@ -1,7 +1,7 @@ import { createTRPCProxyClient } from "@trpc/client" import { windowLink } from "trpc-browser/link" -import { AppRouter } from "../background/trpc/router" +import type { AppRouter } from "../background/trpc/router" import superjson from "superjson" export const inpageMessageClient = createTRPCProxyClient({ diff --git a/packages/extension/src/messages/__tests__/relayer.test.ts b/packages/extension/src/messages/__tests__/relayer.test.ts index 9200c0afa..096309a34 100644 --- a/packages/extension/src/messages/__tests__/relayer.test.ts +++ b/packages/extension/src/messages/__tests__/relayer.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest" import { Relayer } from "../exchange/relayer" -import { Message, Messenger } from "../messenger" +import type { Message, Messenger } from "../messenger" import { WindowMessenger } from "../messenger/window" import { getMockWindow } from "./windowMock.mock" diff --git a/packages/extension/src/messages/__tests__/windowMock.mock.ts b/packages/extension/src/messages/__tests__/windowMock.mock.ts index 14fd3fcda..ec34e2d1a 100644 --- a/packages/extension/src/messages/__tests__/windowMock.mock.ts +++ b/packages/extension/src/messages/__tests__/windowMock.mock.ts @@ -1,7 +1,7 @@ +import type { Handler } from "@argent/x-window" +import { mittx } from "@argent/x-window" import { vi } from "vitest" -import { Handler, mittx } from "starknetkit/window" - // Mock the window object export const getMockWindow = (origin: string): Window => { const window = {} as Window diff --git a/packages/extension/src/messages/exchange/bidirectional.ts b/packages/extension/src/messages/exchange/bidirectional.ts index 3cb5b7fe9..e046971e8 100644 --- a/packages/extension/src/messages/exchange/bidirectional.ts +++ b/packages/extension/src/messages/exchange/bidirectional.ts @@ -1,4 +1,4 @@ -import { Message, Messenger, ResponseMessage } from "../messenger" +import type { Message, Messenger, ResponseMessage } from "../messenger" type AllowPromise = T | Promise type InferPromise = T extends Promise ? U : T diff --git a/packages/extension/src/messages/index.ts b/packages/extension/src/messages/index.ts index cb6807d47..1d765a570 100644 --- a/packages/extension/src/messages/index.ts +++ b/packages/extension/src/messages/index.ts @@ -1,4 +1,4 @@ export * from "./exchange/bidirectional" export * from "./exchange/relayer" -export * from "./messenger" +export type * from "./messenger" export * from "./messenger/window" diff --git a/packages/extension/src/messages/messenger/window.ts b/packages/extension/src/messages/messenger/window.ts index da913478e..34232cf59 100644 --- a/packages/extension/src/messages/messenger/window.ts +++ b/packages/extension/src/messages/messenger/window.ts @@ -1,4 +1,4 @@ -import { Listener, Message, Messenger } from "." +import type { Listener, Message, Messenger } from "." export class WindowMessenger implements Messenger { private readonly listeners: Set diff --git a/packages/extension/src/navigator.d.ts b/packages/extension/src/navigator.d.ts new file mode 100644 index 000000000..3f3d54897 --- /dev/null +++ b/packages/extension/src/navigator.d.ts @@ -0,0 +1,4 @@ +interface Navigator { + /** may be undefined in the wild */ + readonly hid?: HID +} diff --git a/packages/extension/src/shared/account/details/getAccountCairoVersionFromChain.ts b/packages/extension/src/shared/account/details/getAccountCairoVersionFromChain.ts index d95756483..4be4a47ac 100644 --- a/packages/extension/src/shared/account/details/getAccountCairoVersionFromChain.ts +++ b/packages/extension/src/shared/account/details/getAccountCairoVersionFromChain.ts @@ -1,15 +1,15 @@ -import { WalletAccount } from "../../wallet.model" +import type { ArgentWalletAccount } from "../../wallet.model" import { flatten, groupBy, toPairs } from "lodash-es" import { networkService } from "../../network/service" import { getAccountCairoVersion } from "../../utils/argentAccountVersion" export type AccountCairoVersionFromChain = Pick< - WalletAccount, - "address" | "networkId" | "cairoVersion" + ArgentWalletAccount, + "id" | "address" | "networkId" | "cairoVersion" > export async function getAccountCairoVersionFromChain( - accounts: WalletAccount[], + accounts: ArgentWalletAccount[], ): Promise { const accountsByNetwork = toPairs(groupBy(accounts, (a) => a.networkId)) @@ -26,6 +26,7 @@ export async function getAccountCairoVersionFromChain( const result = responses.map((response, i) => { return { + id: accs[i].id, address: accs[i].address, networkId, cairoVersion: response || accs[i].cairoVersion, // If the onchain call fails, keep the cached one diff --git a/packages/extension/src/shared/account/details/getAccountClassHashFromChain.test.ts b/packages/extension/src/shared/account/details/getAccountClassHashFromChain.test.ts index 2ce3ae5bf..250e78e39 100644 --- a/packages/extension/src/shared/account/details/getAccountClassHashFromChain.test.ts +++ b/packages/extension/src/shared/account/details/getAccountClassHashFromChain.test.ts @@ -1,10 +1,10 @@ -import { Mocked, MockedFunction } from "vitest" +import type { Mocked, MockedFunction } from "vitest" import { getAccountClassHashFromChain } from "./getAccountClassHashFromChain" import { tryGetClassHash } from "./tryGetClassHash" import { networkService } from "../../network/service" import { getProvider } from "../../network" import { getMulticallForNetwork } from "../../multicall" -import { getMockWalletAccount } from "../../../../test/walletAccount.mock" +import { getMockArgentWalletAccount } from "../../../../test/walletAccount.mock" import { addressSchema, TXV1_ACCOUNT_CLASS_HASH, @@ -14,6 +14,7 @@ import { getMockNetwork, getMockNetworkWithoutMulticall, } from "../../../../test/network.mock" +import { getRandomAccountIdentifier } from "../../utils/accountIdentifier" vi.mock("../../network/service") vi.mock("../../multicall") @@ -45,7 +46,7 @@ describe("getAccountClassHashFromChain", () => { }) const accounts = [ - getMockWalletAccount({ + getMockArgentWalletAccount({ networkId: mockNetwork.id, network: mockNetwork, classHash: undefined, @@ -84,6 +85,7 @@ describe("getAccountClassHashFromChain", () => { ) expect(results[0]).toEqual({ + id: accounts[0].id, address: accounts[0].address, networkId: accounts[0].networkId, type: "standard", @@ -103,13 +105,14 @@ describe("getAccountClassHashFromChain", () => { }) const accounts = [ - getMockWalletAccount({ + getMockArgentWalletAccount({ address: "0x01", networkId: mockNetwork.id, network: mockNetwork, classHash: TXV1_ACCOUNT_CLASS_HASH, }), - getMockWalletAccount({ + getMockArgentWalletAccount({ + id: getRandomAccountIdentifier("0x02"), address: "0x02", networkId: mockNetwork.id, network: mockNetwork, @@ -173,6 +176,7 @@ describe("getAccountClassHashFromChain", () => { ) expect(results[0]).toEqual({ + id: accounts[0].id, address: accounts[0].address, networkId: accounts[0].networkId, type: "standard", @@ -180,6 +184,7 @@ describe("getAccountClassHashFromChain", () => { }) expect(results[1]).toEqual({ + id: accounts[1].id, address: accounts[1].address, networkId: accounts[1].networkId, type: "multisig", @@ -198,7 +203,7 @@ describe("getAccountClassHashFromChain", () => { }) const accounts = [ - getMockWalletAccount({ + getMockArgentWalletAccount({ networkId: mockNetwork.id, network: mockNetwork, classHash: undefined, @@ -221,6 +226,7 @@ describe("getAccountClassHashFromChain", () => { ) expect(mockTryGetClassHash).toHaveBeenCalledTimes(1) expect(results[0]).toEqual({ + id: accounts[0].id, address: accounts[0].address, networkId: accounts[0].networkId, type: "standard", diff --git a/packages/extension/src/shared/account/details/getAccountClassHashFromChain.ts b/packages/extension/src/shared/account/details/getAccountClassHashFromChain.ts index 5a3bb834f..7113ebfc0 100644 --- a/packages/extension/src/shared/account/details/getAccountClassHashFromChain.ts +++ b/packages/extension/src/shared/account/details/getAccountClassHashFromChain.ts @@ -1,11 +1,17 @@ import { flatten, groupBy, toPairs } from "lodash-es" -import { Call, num } from "starknet" +import type { Call } from "starknet" +import { num } from "starknet" import { getMulticallForNetwork } from "../../multicall" import { getProvider } from "../../network" import { networkService } from "../../network/service" import { mapImplementationToArgentAccountType } from "../../network/utils" -import { ArgentAccountType, WalletAccount } from "../../wallet.model" +import type { + AccountId, + ArgentAccountType, + ArgentWalletAccount, + WalletAccount, +} from "../../wallet.model" import { accountsEqual } from "../../utils/accountsEqual" import { addressSchema, @@ -15,8 +21,8 @@ import { import { tryGetClassHash } from "./tryGetClassHash" export type AccountClassHashFromChain = Pick< - WalletAccount, - "address" | "networkId" | "type" | "classHash" + ArgentWalletAccount, + "id" | "address" | "networkId" | "type" | "classHash" > const getDefaultClassHash = (account: WalletAccount) => { @@ -36,7 +42,7 @@ const getDefaultClassHash = (account: WalletAccount) => { * @returns AccountClassHashFromChain[] */ export async function getAccountClassHashFromChain( - accounts: WalletAccount[], + accounts: ArgentWalletAccount[], ): Promise { const accountsByNetwork = toPairs(groupBy(accounts, (a) => a.networkId)) @@ -48,11 +54,13 @@ export async function getAccountClassHashFromChain( ( account, ): { + id: AccountId classHash: string | undefined call: Call fallbackType: ArgentAccountType } => { return { + id: account.id, classHash: account.classHash || getDefaultClassHash(account), call: { contractAddress: account.address, @@ -70,6 +78,7 @@ export async function getAccountClassHashFromChain( accountTypeCallsByNetwork.map( async ([networkId, classHashWithCalls]): Promise< Array<{ + id: AccountId address: string networkId: string type: ArgentAccountType @@ -96,6 +105,7 @@ export async function getAccountClassHashFromChain( ) const result = responses.map((response, i) => { + const id = classHashWithCalls[i].id const call = classHashWithCalls[i].call const fallbackType = classHashWithCalls[i].fallbackType const type: ArgentAccountType = @@ -105,6 +115,7 @@ export async function getAccountClassHashFromChain( fallbackType, ) return { + id, address: call.contractAddress, networkId, classHash: response, @@ -120,7 +131,8 @@ export async function getAccountClassHashFromChain( ), ) const results: string[] = responses.map((res) => num.toHex(res)) - return classHashWithCalls.map(({ call, fallbackType }, i) => ({ + return classHashWithCalls.map(({ call, fallbackType, id }, i) => ({ + id, address: call.contractAddress, networkId, classHash: results[i], @@ -139,13 +151,14 @@ export async function getAccountClassHashFromChain( const updatedAccount = updatedAccounts.find((x) => accountsEqual(x, account), ) - const { address, networkId } = account + const { address, networkId, id } = account const classHash = updatedAccount?.classHash || account.classHash const parsedClassHash = classHash ? addressSchema.parse(classHash) : undefined return { + id, address, networkId, classHash: parsedClassHash, diff --git a/packages/extension/src/shared/account/details/getAccountDeployStatusFromChain.ts b/packages/extension/src/shared/account/details/getAccountDeployStatusFromChain.ts index 56589d54e..a8703d7d9 100644 --- a/packages/extension/src/shared/account/details/getAccountDeployStatusFromChain.ts +++ b/packages/extension/src/shared/account/details/getAccountDeployStatusFromChain.ts @@ -1,20 +1,21 @@ import { isContractDeployed } from "@argent/x-shared" import { getProvider } from "../../network" -import { WalletAccount } from "../../wallet.model" +import type { ArgentWalletAccount } from "../../wallet.model" export type AccountDeployStatusFromChain = Pick< - WalletAccount, - "address" | "networkId" | "needsDeploy" + ArgentWalletAccount, + "id" | "address" | "networkId" | "needsDeploy" > export async function getAccountDeployStatusFromChain( - accounts: WalletAccount[], + accounts: ArgentWalletAccount[], ): Promise { return Promise.all( - accounts.map(async ({ address, networkId, network }) => { + accounts.map(async ({ address, networkId, network, id }) => { const isDeployed = await isContractDeployed(getProvider(network), address) return { + id, address, networkId, needsDeploy: !isDeployed, diff --git a/packages/extension/src/shared/account/details/getAccountEscapeFromChain.ts b/packages/extension/src/shared/account/details/getAccountEscapeFromChain.ts index a71217dfe..22381c369 100644 --- a/packages/extension/src/shared/account/details/getAccountEscapeFromChain.ts +++ b/packages/extension/src/shared/account/details/getAccountEscapeFromChain.ts @@ -1,14 +1,14 @@ -import { WalletAccount } from "../../wallet.model" +import type { ArgentWalletAccount } from "../../wallet.model" import { getEscapeForAccount } from "./getEscape" export type AccountEscapeFromChain = Pick< - WalletAccount, - "address" | "networkId" | "escape" + ArgentWalletAccount, + "id" | "address" | "networkId" | "escape" > /** updates the accounts with current escape status */ export async function getAccountEscapeFromChain( - accounts: WalletAccount[], + accounts: ArgentWalletAccount[], ): Promise { const escapeResults = await Promise.allSettled( accounts.map((account) => { @@ -20,8 +20,9 @@ export async function getAccountEscapeFromChain( const escapeResult = escapeResults[index] const escape = escapeResult.status === "fulfilled" ? escapeResult.value : undefined - const { address, networkId } = account + const { address, networkId, id } = account return { + id, address, networkId, escape, diff --git a/packages/extension/src/shared/account/details/getAccountGuardiansFromChain.ts b/packages/extension/src/shared/account/details/getAccountGuardiansFromChain.ts index 166cb6c93..919ed5b86 100644 --- a/packages/extension/src/shared/account/details/getAccountGuardiansFromChain.ts +++ b/packages/extension/src/shared/account/details/getAccountGuardiansFromChain.ts @@ -1,14 +1,14 @@ -import { WalletAccount } from "../../wallet.model" +import type { ArgentWalletAccount, WalletAccount } from "../../wallet.model" import { getGuardianForAccount } from "./getGuardian" export type AccountGuardiansFromChain = Pick< - WalletAccount, - "address" | "networkId" | "guardian" + ArgentWalletAccount, + "id" | "address" | "networkId" | "guardian" > /** updates the accounts with current guardian status */ export async function getAccountGuardiansFromChain( - accounts: WalletAccount[], + accounts: ArgentWalletAccount[], ): Promise { const guardianResults = await Promise.allSettled( accounts.map((account) => { @@ -26,8 +26,9 @@ export async function getAccountGuardiansFromChain( : account.type === "multisig" ? "multisig" : "standard" - const { address, networkId } = account + const { address, networkId, id } = account return { + id, address, networkId, guardian, diff --git a/packages/extension/src/shared/account/details/getAndMergeAccountDetails.test.ts b/packages/extension/src/shared/account/details/getAndMergeAccountDetails.test.ts index 3aeb574b0..155f3df0a 100644 --- a/packages/extension/src/shared/account/details/getAndMergeAccountDetails.test.ts +++ b/packages/extension/src/shared/account/details/getAndMergeAccountDetails.test.ts @@ -1,28 +1,35 @@ import { describe, expect, test } from "vitest" -import { WalletAccount } from "../../wallet.model" -import { AccountClassHashFromChain } from "./getAccountClassHashFromChain" +import type { ArgentWalletAccount } from "../../wallet.model" +import type { AccountClassHashFromChain } from "./getAccountClassHashFromChain" import { getAndMergeAccountDetails } from "./getAndMergeAccountDetails" +import { getRandomAccountIdentifier } from "../../utils/accountIdentifier" describe("getAndMergeAccountDetails", () => { describe("when valid", () => { - test("should return the expected account details", () => { + test("should return the expected account details", async () => { const address1 = "0x7e00d496e324876bbc8531f2d9a82bf154d1a04a50218ee74cdd372f75a551a" + const id1 = getRandomAccountIdentifier(address1) + const address2 = "0x69b49c2cc8b16e80e86bfc5b0614a59aa8c9b601569c7b80dde04d3f3151b79" + const id2 = getRandomAccountIdentifier(address2, "mainnet-alpha") + const accounts = [ { + id: id1, address: address1, networkId: "sepolia-alpha", }, { + id: id2, address: address2, networkId: "mainnet-alpha", }, - ] as WalletAccount[] + ] as ArgentWalletAccount[] const getAccountTypesFromChain = async ( - accounts: WalletAccount[], + accounts: ArgentWalletAccount[], ): Promise => { return accounts.map((account) => ({ ...account, @@ -30,14 +37,14 @@ describe("getAndMergeAccountDetails", () => { })) } const getAccountGuardiansFromChain = async ( - accounts: WalletAccount[], + accounts: ArgentWalletAccount[], ): Promise => { return accounts.map((account) => ({ ...account, guardian: account.address === address1 ? "0x1" : "0x2", })) } - expect( + await expect( getAndMergeAccountDetails(accounts, [ getAccountTypesFromChain, getAccountGuardiansFromChain, @@ -47,12 +54,14 @@ describe("getAndMergeAccountDetails", () => { { "address": "0x7e00d496e324876bbc8531f2d9a82bf154d1a04a50218ee74cdd372f75a551a", "guardian": "0x1", + "id": "0x7e00d496e324876bbc8531f2d9a82bf154d1a04a50218ee74cdd372f75a551a::sepolia-alpha::local_secret::0", "networkId": "sepolia-alpha", "type": "standard", }, { "address": "0x69b49c2cc8b16e80e86bfc5b0614a59aa8c9b601569c7b80dde04d3f3151b79", "guardian": "0x2", + "id": "0x69b49c2cc8b16e80e86bfc5b0614a59aa8c9b601569c7b80dde04d3f3151b79::mainnet-alpha::local_secret::0", "networkId": "mainnet-alpha", "type": "plugin", }, diff --git a/packages/extension/src/shared/account/details/getAndMergeAccountDetails.ts b/packages/extension/src/shared/account/details/getAndMergeAccountDetails.ts index b20295d55..512e841d1 100644 --- a/packages/extension/src/shared/account/details/getAndMergeAccountDetails.ts +++ b/packages/extension/src/shared/account/details/getAndMergeAccountDetails.ts @@ -1,10 +1,10 @@ -import { WalletAccount } from "../../wallet.model" +import type { ArgentWalletAccount } from "../../wallet.model" import { accountsEqual } from "../../utils/accountsEqual" -import { getAccountEscapeFromChain } from "./getAccountEscapeFromChain" -import { getAccountGuardiansFromChain } from "./getAccountGuardiansFromChain" -import { getAccountClassHashFromChain } from "./getAccountClassHashFromChain" -import { getAccountDeployStatusFromChain } from "./getAccountDeployStatusFromChain" -import { getAccountCairoVersionFromChain } from "./getAccountCairoVersionFromChain" +import type { getAccountEscapeFromChain } from "./getAccountEscapeFromChain" +import type { getAccountGuardiansFromChain } from "./getAccountGuardiansFromChain" +import type { getAccountClassHashFromChain } from "./getAccountClassHashFromChain" +import type { getAccountDeployStatusFromChain } from "./getAccountDeployStatusFromChain" +import type { getAccountCairoVersionFromChain } from "./getAccountCairoVersionFromChain" export type DetailFetchers = | typeof getAccountDeployStatusFromChain @@ -16,7 +16,7 @@ export type DetailFetchers = /** Use Promise.all allows multicall to batch all calls to get account deatils on chain */ export const getAndMergeAccountDetails = async ( - accounts: WalletAccount[], + accounts: ArgentWalletAccount[], detailFetchers: DetailFetchers[], ) => { const accountsWithAttributesResults = await Promise.all( diff --git a/packages/extension/src/shared/account/details/getEscape.ts b/packages/extension/src/shared/account/details/getEscape.ts index 44b19b39f..a23b41a79 100644 --- a/packages/extension/src/shared/account/details/getEscape.ts +++ b/packages/extension/src/shared/account/details/getEscape.ts @@ -1,14 +1,12 @@ -import { Call, num } from "starknet" +import type { Call } from "starknet" +import { num } from "starknet" import { getMulticallForNetwork } from "../../multicall" import { networkService } from "../../network/service" -import { BaseWalletAccount } from "../../wallet.model" +import type { BaseWalletAccount } from "../../wallet.model" -import { - ESCAPE_TYPE_GUARDIAN, - ESCAPE_TYPE_SIGNER, - Escape, -} from "./escape.model" +import type { Escape } from "./escape.model" +import { ESCAPE_TYPE_GUARDIAN, ESCAPE_TYPE_SIGNER } from "./escape.model" import { multicallWithCairo0Fallback } from "./multicallWithCairo0Fallback" /** diff --git a/packages/extension/src/shared/account/details/getGuardian.ts b/packages/extension/src/shared/account/details/getGuardian.ts index b675779f1..ce5b4d2b6 100644 --- a/packages/extension/src/shared/account/details/getGuardian.ts +++ b/packages/extension/src/shared/account/details/getGuardian.ts @@ -1,8 +1,9 @@ -import { Call, constants, num } from "starknet" +import type { Call } from "starknet" +import { constants, num } from "starknet" import { getMulticallForNetwork } from "../../multicall" import { networkService } from "../../network/service" -import { BaseWalletAccount } from "../../wallet.model" +import type { BaseWalletAccount } from "../../wallet.model" import { multicallWithCairo0Fallback } from "./multicallWithCairo0Fallback" export const getGuardianForAccount = async ( diff --git a/packages/extension/src/shared/account/details/getImplementation.ts b/packages/extension/src/shared/account/details/getImplementation.ts index 7280d1ce1..1f461127e 100644 --- a/packages/extension/src/shared/account/details/getImplementation.ts +++ b/packages/extension/src/shared/account/details/getImplementation.ts @@ -1,12 +1,13 @@ -import { Call, num } from "starknet" +import type { Call } from "starknet" +import { num } from "starknet" import { accountsEqual } from "../../utils/accountsEqual" import { TXV1_ACCOUNT_CLASS_HASH } from "@argent/x-shared" import { getMulticallForNetwork } from "../../multicall" import { getProvider } from "../../network" import { networkService } from "../../network/service" -import { BaseWalletAccount } from "../../wallet.model" -import { IAccountService } from "../service/accountService/IAccountService" +import type { BaseWalletAccount } from "../../wallet.model" +import type { IAccountService } from "../service/accountService/IAccountService" /** * Get implementation class hash of account diff --git a/packages/extension/src/shared/account/details/getOwner.ts b/packages/extension/src/shared/account/details/getOwner.ts index 625f407fa..de9bd2c45 100644 --- a/packages/extension/src/shared/account/details/getOwner.ts +++ b/packages/extension/src/shared/account/details/getOwner.ts @@ -1,8 +1,8 @@ -import { Call } from "starknet" +import type { Call } from "starknet" import { getMulticallForNetwork } from "../../multicall" import { networkService } from "../../network/service" -import { BaseWalletAccount } from "../../wallet.model" +import type { BaseWalletAccount } from "../../wallet.model" import { multicallWithCairo0Fallback } from "./multicallWithCairo0Fallback" /** diff --git a/packages/extension/src/shared/account/details/multicallWithCairo0Fallback.test.ts b/packages/extension/src/shared/account/details/multicallWithCairo0Fallback.test.ts index 4d146e8cc..bcfa30072 100644 --- a/packages/extension/src/shared/account/details/multicallWithCairo0Fallback.test.ts +++ b/packages/extension/src/shared/account/details/multicallWithCairo0Fallback.test.ts @@ -1,6 +1,7 @@ -import { MinimalProviderInterface } from "@argent/x-multicall" -import { Call } from "starknet" -import { Mocked, describe, expect, test } from "vitest" +import type { MinimalProviderInterface } from "@argent/x-multicall" +import type { Call } from "starknet" +import type { Mocked } from "vitest" +import { describe, expect, test } from "vitest" import { multicallWithCairo0Fallback } from "./multicallWithCairo0Fallback" diff --git a/packages/extension/src/shared/account/details/multicallWithCairo0Fallback.ts b/packages/extension/src/shared/account/details/multicallWithCairo0Fallback.ts index b4b509adc..61a9e9926 100644 --- a/packages/extension/src/shared/account/details/multicallWithCairo0Fallback.ts +++ b/packages/extension/src/shared/account/details/multicallWithCairo0Fallback.ts @@ -1,5 +1,5 @@ -import { Call } from "starknet" -import { MinimalProviderInterface } from "@argent/x-multicall" +import type { Call } from "starknet" +import type { MinimalProviderInterface } from "@argent/x-multicall" import { getEntryPointSafe } from "../../utils/transactions" diff --git a/packages/extension/src/shared/account/details/tryGetClassHash.ts b/packages/extension/src/shared/account/details/tryGetClassHash.ts index f7fdd5225..02dc22721 100644 --- a/packages/extension/src/shared/account/details/tryGetClassHash.ts +++ b/packages/extension/src/shared/account/details/tryGetClassHash.ts @@ -1,4 +1,4 @@ -import { Call, ProviderInterface } from "starknet" +import type { Call, ProviderInterface } from "starknet" import { addressSchema, TXV1_ACCOUNT_CLASS_HASH } from "@argent/x-shared" export async function tryGetClassHash( @@ -9,11 +9,11 @@ export async function tryGetClassHash( try { const expected = await provider.callContract(call) return expected[0] - } catch (e) { + } catch { try { const firstFallback = await provider.getClassHashAt(call.contractAddress) return addressSchema.parse(firstFallback) - } catch (e) { + } catch { if (fallbackClassHash) { return fallbackClassHash } diff --git a/packages/extension/src/shared/account/details/updateAccountsWithNames.ts b/packages/extension/src/shared/account/details/updateAccountsWithNames.ts index 30ca64352..f2586e9c0 100644 --- a/packages/extension/src/shared/account/details/updateAccountsWithNames.ts +++ b/packages/extension/src/shared/account/details/updateAccountsWithNames.ts @@ -1,6 +1,6 @@ import { partition } from "lodash-es" -import { WalletAccount } from "../../wallet.model" +import type { WalletAccount } from "../../wallet.model" export const updateAccountsWithNames = (accounts: WalletAccount[]) => { const [multisigAccounts, standardAccounts] = partition( diff --git a/packages/extension/src/shared/account/optimisticImplUpdate.ts b/packages/extension/src/shared/account/optimisticImplUpdate.ts index 578a99b68..c7fb463d4 100644 --- a/packages/extension/src/shared/account/optimisticImplUpdate.ts +++ b/packages/extension/src/shared/account/optimisticImplUpdate.ts @@ -1,9 +1,6 @@ -import { - Address, - isEqualAddress, - getArgentAccountClassHashes, -} from "@argent/x-shared" -import { WalletAccount } from "../wallet.model" +import type { Address } from "@argent/x-shared" +import { isEqualAddress, getArgentAccountClassHashes } from "@argent/x-shared" +import type { WalletAccount } from "../wallet.model" // Use this with caution, as it might not reflect the onchain state, // but just an optimistic update diff --git a/packages/extension/src/shared/account/selectors.ts b/packages/extension/src/shared/account/selectors.ts index 8968f6797..d3568cbe7 100644 --- a/packages/extension/src/shared/account/selectors.ts +++ b/packages/extension/src/shared/account/selectors.ts @@ -1,25 +1,26 @@ -import { memoize } from "lodash-es" +import memoize from "memoizee" -import { BaseWalletAccount, StoredWalletAccount } from "../wallet.model" +import type { BaseWalletAccount, StoredWalletAccount } from "../wallet.model" import { accountsEqual } from "../utils/accountsEqual" export const getAccountSelector = memoize( (baseAccount: BaseWalletAccount) => (account: StoredWalletAccount) => accountsEqual(account, baseAccount), + { normalizer: ([account]) => account.id }, ) export const getNetworkSelector = memoize( (networkId: string) => (account: StoredWalletAccount) => account.networkId === networkId, + { primitive: true }, ) export const withoutHiddenSelector = (account: StoredWalletAccount) => !account.hidden -export const withHiddenSelector = memoize( - () => true, - () => "default", -) +export const withHiddenSelector = memoize(() => true, { + normalizer: () => "default", +}) export const withGuardianSelector = (account: StoredWalletAccount) => Boolean(account.guardian) diff --git a/packages/extension/src/shared/account/service/accountService/AccountService.test.ts b/packages/extension/src/shared/account/service/accountService/AccountService.test.ts index 858be0f95..f6f311cd3 100644 --- a/packages/extension/src/shared/account/service/accountService/AccountService.test.ts +++ b/packages/extension/src/shared/account/service/accountService/AccountService.test.ts @@ -1,15 +1,30 @@ +import type { Mocked } from "vitest" import { mockChainService } from "../../../chain/service/__test__/mock" import { MockFnRepository } from "../../../storage/__new/__test__/mockFunctionImplementation" import type { BaseWalletAccount, WalletAccount } from "../../../wallet.model" import { AccountService } from "./AccountService" +import type { IPKManager } from "../../../accountImport/pkManager/IPKManager" +import { emitterMock } from "../../../test.utils" describe("AccountService", () => { let accountRepo: MockFnRepository let accountService: AccountService + let mockPkManager: Mocked + beforeEach(() => { accountRepo = new MockFnRepository() - accountService = new AccountService(mockChainService, accountRepo) + mockPkManager = { + storeEncryptedKey: vi.fn(), + retrieveDecryptedKey: vi.fn(), + removeKey: vi.fn(), + } as Mocked + accountService = new AccountService( + emitterMock, + mockChainService, + accountRepo, + mockPkManager, + ) }) describe("get", () => { @@ -40,10 +55,13 @@ describe("AccountService", () => { describe("remove", () => { it("should remove accounts from the accountRepo", async () => { const baseAccount: BaseWalletAccount = { + id: "0x123-0x1-local_secret", address: "0x123", networkId: "0x1", } - await accountService.remove(baseAccount) + + accountRepo.get.mockResolvedValue([baseAccount]) + await accountService.removeById(baseAccount.id) expect(accountRepo.remove).toHaveBeenCalled() }) @@ -52,6 +70,7 @@ describe("AccountService", () => { describe("getDeployed", () => { it("should return mock value", async () => { const result = await accountService.getDeployed({ + id: "0x123-0x1-local_secret", address: "0x123", networkId: "0x1", }) diff --git a/packages/extension/src/shared/account/service/accountService/AccountService.ts b/packages/extension/src/shared/account/service/accountService/AccountService.ts index f9d59e7b5..9de68f575 100644 --- a/packages/extension/src/shared/account/service/accountService/AccountService.ts +++ b/packages/extension/src/shared/account/service/accountService/AccountService.ts @@ -1,18 +1,26 @@ -import { IChainService } from "../../../chain/service/IChainService" +import type { IChainService } from "../../../chain/service/IChainService" import type { AllowArray, SelectorFn } from "../../../storage/__new/interface" +import type { AccountId, ArgentWalletAccount } from "../../../wallet.model" import { type BaseWalletAccount, type WalletAccount, } from "../../../wallet.model" -import { accountsEqual } from "../../../utils/accountsEqual" +import { accountsEqual, isEqualAccountIds } from "../../../utils/accountsEqual" import { withoutHiddenSelector } from "../../selectors" import type { IAccountRepo } from "../../store" -import { IAccountService } from "./IAccountService" +import type { Events, IAccountService } from "./IAccountService" +import { AccountAddedEvent } from "./IAccountService" +import type { IPKManager } from "../../../accountImport/pkManager/IPKManager" +import type Emittery from "emittery" +import { ensureArray } from "@argent/x-shared" +import { filterArgentAccounts } from "../../../utils/isExternalAccount" export class AccountService implements IAccountService { constructor( + public readonly emitter: Emittery, private readonly chainService: IChainService, private readonly accountRepo: IAccountRepo, + private readonly pkManager: IPKManager, ) {} async get( @@ -21,6 +29,11 @@ export class AccountService implements IAccountService { return this.accountRepo.get(selector) } + async getArgentWalletAccounts(): Promise { + const accounts = await this.get() + return filterArgentAccounts(accounts) + } + async getFromBaseWalletAccounts(baseWalletAccounts: BaseWalletAccount[]) { const accounts = await this.get((account) => { return baseWalletAccounts.some((baseWalletAccount) => @@ -32,12 +45,32 @@ export class AccountService implements IAccountService { async upsert(account: AllowArray): Promise { await this.accountRepo.upsert(account) + + for (const accountItem of ensureArray(account)) { + await this.emitter.emit(AccountAddedEvent, accountItem) + } + } + + async remove( + selector: SelectorFn | AllowArray, + ): Promise { + return this.accountRepo.remove(selector) } - async remove(baseAccount: BaseWalletAccount): Promise { - await this.accountRepo.remove((account) => - accountsEqual(account, baseAccount), + async removeById(accountId: AccountId): Promise { + const [account] = await this.accountRepo.get((account) => + isEqualAccountIds(account.id, accountId), ) + + if (!account) { + return + } + + await this.accountRepo.remove(account) + + if (account.type === "imported") { + await this.pkManager.removeKey(account.id) + } } // TBD: should we expose this function and get rid of one function per property? Or should we keep it as is? @@ -55,19 +88,16 @@ export class AccountService implements IAccountService { }) } - async setHide( - hidden: boolean, - baseAccount: BaseWalletAccount, - ): Promise { + async setHide(hidden: boolean, accountId: AccountId): Promise { return this.update( - (account) => accountsEqual(account, baseAccount), + (account) => isEqualAccountIds(account.id, accountId), (account) => ({ ...account, hidden }), ) } - async setName(name: string, baseAccount: BaseWalletAccount): Promise { + async setName(name: string, accountId: AccountId): Promise { return this.update( - (account) => accountsEqual(account, baseAccount), + (account) => isEqualAccountIds(account.id, accountId), (account) => ({ ...account, name }), ) } diff --git a/packages/extension/src/shared/account/service/accountService/IAccountService.ts b/packages/extension/src/shared/account/service/accountService/IAccountService.ts index f4b6f4e61..74ff578b6 100644 --- a/packages/extension/src/shared/account/service/accountService/IAccountService.ts +++ b/packages/extension/src/shared/account/service/accountService/IAccountService.ts @@ -1,18 +1,37 @@ +import type Emittery from "emittery" import type { AllowArray, SelectorFn } from "../../../storage/__new/interface" -import type { BaseWalletAccount, WalletAccount } from "../../../wallet.model" +import type { + AccountId, + ArgentWalletAccount, + BaseWalletAccount, + WalletAccount, +} from "../../../wallet.model" + +export const AccountAddedEvent = Symbol("AccountAddedEvent") + +export type Events = { + [AccountAddedEvent]: WalletAccount +} export interface IAccountService { + readonly emitter: Emittery + // Repo methods + getArgentWalletAccounts(): Promise get(selector?: SelectorFn): Promise getFromBaseWalletAccounts( baseWalletAccounts: BaseWalletAccount[], ): Promise upsert(account: AllowArray): Promise - remove(baseAccount: BaseWalletAccount): Promise + remove( + selector?: SelectorFn | AllowArray, + ): Promise + + removeById(accountId: AccountId): Promise // mutations/updates - setHide(hidden: boolean, baseAccount: BaseWalletAccount): Promise - setName(name: string, baseAccount: BaseWalletAccount): Promise + setHide(hidden: boolean, accountId: AccountId): Promise + setName(name: string, accountId: AccountId): Promise // getters getDeployed(baseAccount: BaseWalletAccount): Promise diff --git a/packages/extension/src/shared/account/service/accountService/index.ts b/packages/extension/src/shared/account/service/accountService/index.ts index 9accbe669..8bdbd5e64 100644 --- a/packages/extension/src/shared/account/service/accountService/index.ts +++ b/packages/extension/src/shared/account/service/accountService/index.ts @@ -1,8 +1,15 @@ +import Emittery from "emittery" +import { pkManager } from "../../../accountImport/pkManager" import { starknetChainService } from "../../../chain/service" import { accountRepo } from "../../store" import { AccountService } from "./AccountService" +import type { Events } from "./IAccountService" + +const emitter = new Emittery() export const accountService = new AccountService( + emitter, starknetChainService, accountRepo, + pkManager, ) diff --git a/packages/extension/src/shared/account/service/accountSharedService/WalletAccountSharedService.test.ts b/packages/extension/src/shared/account/service/accountSharedService/WalletAccountSharedService.test.ts index 1032cdb5c..996d2df34 100644 --- a/packages/extension/src/shared/account/service/accountSharedService/WalletAccountSharedService.test.ts +++ b/packages/extension/src/shared/account/service/accountSharedService/WalletAccountSharedService.test.ts @@ -1,16 +1,17 @@ -import { - WalletAccountSharedService, - WalletSession, -} from "./WalletAccountSharedService" -import { WalletStorageProps } from "../../../wallet/walletStore" - -import { - BaseMultisigWalletAccount, - defaultNetworkOnlyPlaceholderAccount, -} from "../../../wallet.model" -import { PendingMultisig } from "../../../multisig/types" - -import { WalletAccount } from "../../../wallet.model" +import type { WalletSession } from "./WalletAccountSharedService" +import { WalletAccountSharedService } from "./WalletAccountSharedService" +import type { WalletStorageProps } from "../../../wallet/walletStore" + +import type { BaseMultisigWalletAccount } from "../../../wallet.model" +import { defaultNetworkOnlyPlaceholderAccount } from "../../../wallet.model" +import type { PendingMultisig } from "../../../multisig/types" + +import type { WalletAccount } from "../../../wallet.model" +import type { + IObjectStore, + IRepository, +} from "../../../storage/__new/interface" +import { getRandomAccountIdentifier } from "../../../utils/accountIdentifier" import { accountServiceMock, getMultisigStoreMock, @@ -19,11 +20,7 @@ import { getStoreMock, getWalletStoreMock, httpServiceMock, - // Doesnt matter for tests - // eslint-disable-next-line @argent/local/code-import-patterns -} from "../../../../background/wallet/test.utils" -import { IObjectStore, IRepository } from "../../../storage/__new/interface" -import { defaultNetwork } from "../../../network" +} from "../../../test.utils" describe("WalletAccountSharedService", () => { let service: WalletAccountSharedService @@ -62,7 +59,7 @@ describe("WalletAccountSharedService", () => { accountServiceMock, ) - const result = await service.getAccount(accountMock) + const result = await service.getAccount(accountMock.id) expect(walletStoreMock.get).toHaveBeenCalledWith(expect.any(Function)) expect(result).toEqual(accountMock) @@ -92,7 +89,7 @@ describe("WalletAccountSharedService", () => { accountServiceMock, ) - await expect(service.getAccount(accountMock)).rejects.toThrow( + await expect(service.getAccount(accountMock.id)).rejects.toThrow( "Account not found", ) }) @@ -207,10 +204,6 @@ describe("WalletAccountSharedService", () => { }) it("should throw an error when the selected account is not found", async () => { - const accountIdentifierMock = { - address: "address", - networkId: "networkId", - } const accountsMock = [ { address: "address1", networkId: "networkId1" }, { address: "address2", networkId: "networkId2" }, @@ -233,19 +226,23 @@ describe("WalletAccountSharedService", () => { accountServiceMock, ) - await expect( - service.selectAccount(accountIdentifierMock), - ).rejects.toThrow("Account not found") + await expect(service.selectAccount("abc")).rejects.toThrow( + "Account not found", + ) }) it("should set selected account and return it when a valid account identifier is provided", async () => { - const accountIdentifierMock = { - address: "0x2", - networkId: "networkId2", - } + const accountIdentifierMock = getRandomAccountIdentifier( + "0x2", + "networkId2", + ) const accountsMock = [ - { address: "0x1", networkId: "networkId1" }, - { address: "0x2", networkId: "networkId2" }, + { + address: "0x1", + networkId: "networkId1", + id: getRandomAccountIdentifier(), + }, + { address: "0x2", networkId: "networkId2", id: accountIdentifierMock }, ] as WalletAccount[] storeMock = getStoreMock() walletStoreMock = getWalletStoreMock({ @@ -271,6 +268,7 @@ describe("WalletAccountSharedService", () => { expect(storeMock.set).toHaveBeenCalledWith({ selected: { + id: accountIdentifierMock, address: accountsMock[1].address, networkId: accountsMock[1].networkId, }, @@ -281,9 +279,11 @@ describe("WalletAccountSharedService", () => { describe("getMultisigAccount", () => { it("should return the multisig wallet account when found", async () => { + const id = getRandomAccountIdentifier() const accountIdentifierMock = { address: "address", networkId: "networkId", + id, } const walletAccountMock = { address: "address", @@ -319,7 +319,7 @@ describe("WalletAccountSharedService", () => { accountServiceMock, ) - const result = await service.getMultisigAccount(accountIdentifierMock) + const result = await service.getMultisigAccount(accountIdentifierMock.id) expect(walletStoreMock.get).toHaveBeenCalledWith(expect.any(Function)) expect(multisigStoreMock.get).toHaveBeenCalledWith(expect.any(Function)) @@ -327,9 +327,11 @@ describe("WalletAccountSharedService", () => { }) it("should throw an error when multisig wallet account not found", async () => { + const id = getRandomAccountIdentifier() const accountIdentifierMock = { address: "address", networkId: "networkId", + id, } const walletAccountMock = { address: "address", @@ -358,8 +360,76 @@ describe("WalletAccountSharedService", () => { ) await expect( - service.getMultisigAccount(accountIdentifierMock), + service.getMultisigAccount(accountIdentifierMock.id), ).rejects.toThrow("Multisig base wallet account not found") }) }) + + describe("getLastUsedAccountOnNetwork", () => { + it("should return the last used account on network", async () => { + const accountsMock = [ + { address: "address1", networkId: "networkId1" }, + { address: "address2", networkId: "networkId2" }, + ] as WalletAccount[] + + storeMock = getStoreMock({ + set: vi.fn(), + subscribe: vi.fn(), + namespace: "", + get: vi.fn(() => + Promise.resolve({ + selected: { + id: accountsMock[0].id, + address: accountsMock[0].address, + networkId: accountsMock[0].networkId, + }, + lastUsedAccountByNetwork: { + networkId1: { + id: accountsMock[0].id, + address: accountsMock[0].address, + networkId: accountsMock[0].networkId, + }, + }, + }), + ), + }) + walletStoreMock = getWalletStoreMock({ + get: vi.fn(() => Promise.resolve(accountsMock)), + }) + sessionStoreMock = getSessionStoreMock() + multisigStoreMock = getMultisigStoreMock() + pendingMultisigStoreMock = getPendingMultisigStoreMock() + + service = new WalletAccountSharedService( + storeMock, + walletStoreMock, + sessionStoreMock, + multisigStoreMock, + pendingMultisigStoreMock, + httpServiceMock, + accountServiceMock, + ) + + await service.selectAccount(accountsMock[0].id) + const result = await service.getSelectedAccount() + + expect(sessionStoreMock.get).toHaveBeenCalled() + expect(walletStoreMock.get).toHaveBeenCalled() + expect(storeMock.get).toHaveBeenCalled() + + expect(result).toEqual({ + id: accountsMock[0].id, + address: accountsMock[0].address, + networkId: accountsMock[0].networkId, + }) + + const lastUsedAccountByNetwork = + await service.getLastUsedAccountOnNetwork("networkId1") + expect(lastUsedAccountByNetwork).toEqual({ + id: accountsMock[0].id, + address: accountsMock[0].address, + networkId: accountsMock[0].networkId, + }) + }) + }) }) diff --git a/packages/extension/src/shared/account/service/accountSharedService/WalletAccountSharedService.ts b/packages/extension/src/shared/account/service/accountSharedService/WalletAccountSharedService.ts index 53b3989ca..26051d024 100644 --- a/packages/extension/src/shared/account/service/accountSharedService/WalletAccountSharedService.ts +++ b/packages/extension/src/shared/account/service/accountSharedService/WalletAccountSharedService.ts @@ -1,31 +1,38 @@ import { find, partition } from "lodash-es" +import type { IHttpService } from "@argent/x-shared" import { addressSchemaArgentBackend, BaseError, ensureArray, getLatestArgentMultisigClassHash, - IHttpService, } from "@argent/x-shared" import { ARGENT_ACCOUNT_URL, ARGENT_ACCOUNTS_URL } from "../../../api/constants" import { AccountError } from "../../../errors/account" -import { PendingMultisig } from "../../../multisig/types" +import type { PendingMultisig } from "../../../multisig/types" import { defaultNetwork } from "../../../network" -import { IObjectStore, IRepository } from "../../../storage/__new/interface" -import { accountsEqual } from "../../../utils/accountsEqual" +import type { + IObjectStore, + IRepository, +} from "../../../storage/__new/interface" +import { accountsEqual, isEqualAccountIds } from "../../../utils/accountsEqual" import { getIndexForPath, getPathForIndex } from "../../../utils/derivationPath" -import { +import type { + AccountId, + ArgentWalletAccount, BaseMultisigWalletAccount, BaseWalletAccount, CreateAccountType, - defaultNetworkOnlyPlaceholderAccount, - isNetworkOnlyPlaceholderAccount, MultisigWalletAccount, NetworkOnlyPlaceholderAccount, - SignerType, WalletAccount, WalletAccountSigner, } from "../../../wallet.model" +import { + defaultNetworkOnlyPlaceholderAccount, + isNetworkOnlyPlaceholderAccount, + SignerType, +} from "../../../wallet.model" import type { WalletStorageProps } from "../../../wallet/walletStore" import { withHiddenSelector } from "../../selectors" @@ -33,7 +40,10 @@ import urlJoin from "url-join" import { getBaseDerivationPath } from "../../../signer/utils" import { SMART_ACCOUNT_NETWORK_ID } from "../../../smartAccount/constants" import { generateJwt } from "../../../smartAccount/jwt" -import { IAccountService } from "../accountService/IAccountService" +import type { IAccountService } from "../accountService/IAccountService" +import { walletAccountToArgentAccount } from "../../../utils/isExternalAccount" +import { getAccountIdentifier } from "../../../utils/accountIdentifier" +import { toBaseWalletAccount } from "../../utils" export interface WalletSession { secret: string @@ -96,19 +106,23 @@ export class WalletAccountSharedService { name, classHash, signerType = SignerType.LOCAL_SECRET, - signer, - }: GetAccountArgs): WalletAccount { + signer: providedSigner, + }: GetAccountArgs): ArgentWalletAccount { const baseDerivationPath = getBaseDerivationPath("standard", signerType) + + const signer = providedSigner ?? { + type: signerType, + derivationPath: getPathForIndex(index, baseDerivationPath), + } + return { + id: getAccountIdentifier(address, network.id, signer), name: name || `Account ${index + 1}`, address, network, networkId: network.id, type: "standard", - signer: signer || { - derivationPath: getPathForIndex(index, baseDerivationPath), - type: signerType, - }, + signer, classHash, needsDeploy, } @@ -122,18 +136,22 @@ export class WalletAccountSharedService { name, classHash, signerType = SignerType.LOCAL_SECRET, - }: GetAccountArgs): WalletAccount { + signer: providedSigner, + }: GetAccountArgs): ArgentWalletAccount { const baseDerivationPath = getBaseDerivationPath("smart", signerType) + const signer = providedSigner ?? { + type: signerType, + derivationPath: getPathForIndex(index, baseDerivationPath), + } + return { + id: getAccountIdentifier(address, network.id, signer, false), name: name || `Account ${index + 1}`, address, network, networkId: network.id, type: "smart", - signer: { - derivationPath: getPathForIndex(index, baseDerivationPath), - type: signerType, - }, + signer, classHash, needsDeploy, } @@ -146,18 +164,23 @@ export class WalletAccountSharedService { needsDeploy, name, signerType = SignerType.LOCAL_SECRET, - }: GetAccountArgs): WalletAccount { + signer: providedSigner, + }: GetAccountArgs): ArgentWalletAccount { const baseDerivationPath = getBaseDerivationPath("multisig", signerType) + + const signer = providedSigner ?? { + type: signerType, + derivationPath: getPathForIndex(index, baseDerivationPath), + } + return { + id: getAccountIdentifier(address, network.id, signer), name: name || `Multisig ${index + 1}`, address, networkId: network.id, network, type: "multisig", - signer: { - derivationPath: getPathForIndex(index, baseDerivationPath), - type: signerType, - }, + signer, classHash: getLatestArgentMultisigClassHash(), cairoVersion: "1", needsDeploy, @@ -165,11 +188,9 @@ export class WalletAccountSharedService { } // TODO rewrite using views, move out of service and rename to accountView - public async getAccount( - selector: BaseWalletAccount, - ): Promise { + public async getAccount(accountId: AccountId): Promise { const [hit] = await this.walletStore.get((account) => - accountsEqual(account, selector), + isEqualAccountIds(account.id, accountId), ) if (!hit) { throw new AccountError({ code: "NOT_FOUND" }) @@ -177,6 +198,17 @@ export class WalletAccountSharedService { return hit } + public async getArgentAccount( + accountId: AccountId, + ): Promise { + const account = await this.getAccount(accountId) + if (!account) { + return null + } + + return walletAccountToArgentAccount(account) + } + public async getSelectedAccount(): Promise { // Replace with session service once instantiated if ((await this.sessionStore.get()) === null) { @@ -198,7 +230,7 @@ export class WalletAccountSharedService { public async selectAccount( accountIdentifier: - | BaseWalletAccount + | AccountId | NetworkOnlyPlaceholderAccount = defaultNetworkOnlyPlaceholderAccount, ) { if (isNetworkOnlyPlaceholderAccount(accountIdentifier)) { @@ -208,17 +240,20 @@ export class WalletAccountSharedService { const accounts = await this.walletStore.get() const account = find(accounts, (account) => - accountsEqual(account, accountIdentifier), + isEqualAccountIds(account.id, accountIdentifier), ) if (!account) { throw new AccountError({ code: "NOT_FOUND" }) } - const { address, networkId } = account // makes sure to strip away unused properties - await this.store.set({ selected: { address, networkId } }) + const baseAccount = toBaseWalletAccount(account) + await this.store.set({ selected: baseAccount }) - await this.updateLastUsedAccountOnNetwork(networkId, account) + await this.updateLastUsedAccountOnNetwork( + baseAccount.networkId, + baseAccount, + ) return account } @@ -233,7 +268,8 @@ export class WalletAccountSharedService { lastUsedAccountByNetwork = {} } if (!accountsEqual(lastUsedAccountByNetwork[networkId], account)) { - lastUsedAccountByNetwork[networkId] = account + const baseAccount = toBaseWalletAccount(account) + lastUsedAccountByNetwork[networkId] = baseAccount await this.store.set({ lastUsedAccountByNetwork }) } } @@ -249,29 +285,24 @@ export class WalletAccountSharedService { if (!accountExists) { const updatedAccounts = [...currentAccounts, account] await this.store.set({ - noUpgradeBannerAccounts: updatedAccounts.map( - ({ address, networkId }) => ({ - address, - networkId, - }), - ), + noUpgradeBannerAccounts: updatedAccounts.map(toBaseWalletAccount), }) } } public async getMultisigAccount( - selector: BaseWalletAccount, + accountId: AccountId, ): Promise { const [walletAccount] = await this.walletStore.get( (account) => - accountsEqual(account, selector) && account.type === "multisig", + isEqualAccountIds(account.id, accountId) && account.type === "multisig", ) if (!walletAccount) { throw new AccountError({ code: "MULTISIG_NOT_FOUND" }) } const [multisigBaseWalletAccount] = await this.multisigStore.get( - (account) => accountsEqual(account, selector), + (account) => isEqualAccountIds(account.id, accountId), ) if (!multisigBaseWalletAccount) { @@ -322,7 +353,7 @@ export class WalletAccountSharedService { "Content-Type": "application/json", }, }) - } catch (e) { + } catch { throw new BaseError({ message: "Failed to send account name to backend" }) } } @@ -371,7 +402,7 @@ export class WalletAccountSharedService { // BE does not retrieve the networkId because smart accounts are not multi-network. E.g. in prod you cannot add smart accounts on Sepolia return { ...account, networkId: SMART_ACCOUNT_NETWORK_ID } }) - } catch (e) { + } catch { throw new BaseError({ message: "Failed to send account name to backend" }) } } @@ -385,7 +416,7 @@ export class WalletAccountSharedService { // and each call to update attempts to read, modify, and write the accounts array in the accountRepo almost simultaneously. // This can lead to race conditions where some updates overwrite others because they all read the accounts array before any of them has a chance to write their updates back. for (const account of accounts) { - await this.accountService.setName(account.name, account) + await this.accountService.setName(account.name, account.id) } } diff --git a/packages/extension/src/shared/account/store/serialize.test.ts b/packages/extension/src/shared/account/store/serialize.test.ts index bea852740..142b1acfc 100644 --- a/packages/extension/src/shared/account/store/serialize.test.ts +++ b/packages/extension/src/shared/account/store/serialize.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest" -import { Network } from "../../network" +import type { Network } from "../../network" import { defaultNetworks } from "../../network/defaults" import { SignerType, @@ -8,9 +8,12 @@ import { type WalletAccount, } from "../../wallet.model" import { deserializeFactory, migrateAccount, serialize } from "./serialize" +import { getRandomAccountIdentifier } from "../../utils/accountIdentifier" const defaultNetwork = defaultNetworks[1] +const accountId = getRandomAccountIdentifier("0x1", defaultNetwork.id) + // Mock getNetwork function const getNetwork = vi.fn(async (networkId): Promise => { if (networkId !== defaultNetwork.id) { @@ -23,6 +26,7 @@ const deserialize = deserializeFactory(getNetwork) const mockAccounts: WalletAccount[] = [ { + id: accountId, name: "Account1", type: "standard", address: "0x1", @@ -34,6 +38,7 @@ const mockAccounts: WalletAccount[] = [ const mockStoredAccounts: StoredWalletAccount[] = [ { + id: accountId, name: "Account1", type: "standard", address: "0x1", @@ -57,6 +62,7 @@ describe("Wallet Account Serialization and Deserialization", () => { const invalidStoredAccounts: StoredWalletAccount[] = [ ...mockStoredAccounts, { + id: accountId, name: "Account2", type: "standard", address: "0x2", diff --git a/packages/extension/src/shared/account/store/session.ts b/packages/extension/src/shared/account/store/session.ts index 0979e6794..7a537afb8 100644 --- a/packages/extension/src/shared/account/store/session.ts +++ b/packages/extension/src/shared/account/store/session.ts @@ -1,6 +1,6 @@ import { ObjectStorage } from "../../storage" import { adaptObjectStorage } from "../../storage/__new/object" -import { WalletSession } from "../service/accountSharedService/WalletAccountSharedService" +import type { WalletSession } from "../service/accountSharedService/WalletAccountSharedService" /** * @deprecated use `sessionRepo` instead diff --git a/packages/extension/src/shared/account/storeMigration.ts b/packages/extension/src/shared/account/storeMigration.ts index 70743e9e3..afd4898f3 100644 --- a/packages/extension/src/shared/account/storeMigration.ts +++ b/packages/extension/src/shared/account/storeMigration.ts @@ -2,7 +2,8 @@ import { uniqWith } from "lodash-es" import browser from "webextension-polyfill" import { networkService } from "../network/service" -import { SignerType, WalletAccount } from "../wallet.model" +import type { WalletAccount } from "../wallet.model" +import { SignerType } from "../wallet.model" import { accountsEqual } from "../utils/accountsEqual" import { accountService } from "./service" diff --git a/packages/extension/src/shared/account/update.ts b/packages/extension/src/shared/account/update.ts index b0cf86c8c..b2386f324 100644 --- a/packages/extension/src/shared/account/update.ts +++ b/packages/extension/src/shared/account/update.ts @@ -1,14 +1,15 @@ import { accountsEqual, accountsEqualByChainId } from "../utils/accountsEqual" -import { BaseMultisigWalletAccount, WalletAccount } from "./../wallet.model" +import type { + BaseMultisigWalletAccount, + WalletAccount, +} from "./../wallet.model" import { getMultisigAccounts } from "../multisig/utils/baseMultisig" -import { BaseWalletAccount } from "../wallet.model" +import type { BaseWalletAccount } from "../wallet.model" import { getAccountEscapeFromChain } from "./details/getAccountEscapeFromChain" import { getAccountGuardiansFromChain } from "./details/getAccountGuardiansFromChain" import { getAccountClassHashFromChain } from "./details/getAccountClassHashFromChain" -import { - DetailFetchers, - getAndMergeAccountDetails, -} from "./details/getAndMergeAccountDetails" +import type { DetailFetchers } from "./details/getAndMergeAccountDetails" +import { getAndMergeAccountDetails } from "./details/getAndMergeAccountDetails" import { accountService } from "./service" import { multisigBaseWalletRepo } from "../multisig/repository" import { getProvider } from "../network" @@ -16,6 +17,7 @@ import { networkService } from "../network/service" import { MultisigEntryPointType } from "../multisig/types" import { getAccountCairoVersionFromChain } from "./details/getAccountCairoVersionFromChain" import { RpcBatchProvider } from "@argent/x-multicall" +import { isArgentAccount } from "../utils/isExternalAccount" type UpdateScope = "all" | "implementation" | "deploy" | "guardian" @@ -51,6 +53,7 @@ export async function updateAccountDetails( const deployedAccounts = allAccounts .concat(newAccounts) + .filter(isArgentAccount) .filter((acc) => !acc.needsDeploy) // Only fetch account details for deployed accounts diff --git a/packages/extension/src/shared/account/utils.ts b/packages/extension/src/shared/account/utils.ts new file mode 100644 index 000000000..caaa457c2 --- /dev/null +++ b/packages/extension/src/shared/account/utils.ts @@ -0,0 +1,12 @@ +import type { BaseWalletAccount, WalletAccount } from "../wallet.model" + +/** + * Transforms a WalletAccount into a BaseWalletAccount + * @returns A BaseWalletAccount containing only id, address, and networkId + */ +export const toBaseWalletAccount = ( + walletAccount: WalletAccount | BaseWalletAccount, +): BaseWalletAccount => { + const { id, address, networkId } = walletAccount + return { id, address, networkId } +} diff --git a/packages/extension/src/shared/accountImport/account.ts b/packages/extension/src/shared/accountImport/account.ts new file mode 100644 index 000000000..7e7ac6e6a --- /dev/null +++ b/packages/extension/src/shared/accountImport/account.ts @@ -0,0 +1,60 @@ +import type { Address } from "@argent/x-shared" +import { addressSchema } from "@argent/x-shared" +import type { + AccountInterface, + CairoVersion, + ProviderInterface, + Signature, + TypedData, +} from "starknet" +import { PrivateKeySigner } from "../signer/PrivateKeySigner" +import { BaseStarknetAccount } from "../starknetAccount/base" +import type { BaseSignerInterface } from "../signer/BaseSignerInterface" + +export class ImportedAccount extends BaseStarknetAccount { + private static supportedSigners = [PrivateKeySigner] + + constructor( + provider: ProviderInterface, + address: Address, + signer: PrivateKeySigner, + cairoVersion: CairoVersion = "1", + classHash: string | undefined, + ) { + super(provider, address, signer, cairoVersion, classHash) + this.signer = signer + } + + public static fromAccount( + account: AccountInterface, + signer: BaseSignerInterface, + classHash: string | undefined, + ) { + if (!this.isValidSigner(signer)) { + throw new Error( + "Unsupported signer for ImportedAccount: " + signer.signerType, + ) + } + const address = addressSchema.parse(account.address) + + return new ImportedAccount( + account, + address, + signer, + account.cairoVersion, + classHash, + ) + } + + async signMessage(typedData: TypedData): Promise { + return this.signer.signMessage(typedData, this.address) + } + + public static isValidSigner( + signer: BaseSignerInterface, + ): signer is PrivateKeySigner { + return this.supportedSigners.some((supportedSigner) => + supportedSigner.isValid(signer), + ) + } +} diff --git a/packages/extension/src/shared/accountImport/pkManager/IPKManager.ts b/packages/extension/src/shared/accountImport/pkManager/IPKManager.ts new file mode 100644 index 000000000..afb0afe5c --- /dev/null +++ b/packages/extension/src/shared/accountImport/pkManager/IPKManager.ts @@ -0,0 +1,13 @@ +import type { Hex } from "@argent/x-shared" +import type { AccountId } from "../../wallet.model" + +export interface IPKManager { + storeEncryptedKey( + pk: Hex, + password: string, + accountId: AccountId, + ): Promise + retrieveDecryptedKey(password: string, accountId: AccountId): Promise + + removeKey(accountId: AccountId): Promise +} diff --git a/packages/extension/src/shared/accountImport/pkManager/PKManager.test.ts b/packages/extension/src/shared/accountImport/pkManager/PKManager.test.ts new file mode 100644 index 000000000..726ee4285 --- /dev/null +++ b/packages/extension/src/shared/accountImport/pkManager/PKManager.test.ts @@ -0,0 +1,128 @@ +import { describe, it, expect, beforeEach } from "vitest" +import { PKManager } from "./PKManager" +import type { AccountId } from "../../wallet.model" +import type { IPKStore } from "../types" +import { MockFnObjectStore } from "../../storage/__new/__test__/mockFunctionImplementation" +import { getMockAccount } from "../../../../test/account.mock" + +describe("PKManager", () => { + let pkManager: PKManager + let mockStore: MockFnObjectStore + + beforeEach(() => { + mockStore = new MockFnObjectStore() + + mockStore.get.mockResolvedValueOnce({ keystore: {} }) + + pkManager = new PKManager(mockStore, 1024) // Using a low N value for faster tests + }) + + it("should store an encrypted key", async () => { + const accountId = "0x123" as AccountId + const privateKey = "0xabcdef1234567890" + const password = "testpassword" + + mockStore.set.mockResolvedValue(undefined) + + await pkManager.storeEncryptedKey(privateKey, password, accountId) + + expect(mockStore.set).toHaveBeenCalledWith( + expect.objectContaining({ + keystore: expect.objectContaining({ + [accountId]: expect.objectContaining({ + nonce: expect.any(String), + salt: expect.any(String), + encryptedKey: expect.any(String), + }), + }), + }), + ) + }) + + it("should retrieve and decrypt a stored key", async () => { + const accountId = getMockAccount().id + const privateKey = "0xabcdef1234567890" + const password = "testpassword" + + // First, store the key + await pkManager.storeEncryptedKey(privateKey, password, accountId) + + // Mock the get method to return the stored data + mockStore.get.mockResolvedValue({ + keystore: { + [accountId]: await pkManager["encryptKey"](privateKey, password), + }, + }) + + const retrievedKey = await pkManager.retrieveDecryptedKey( + password, + accountId, + ) + expect(retrievedKey).toBe(privateKey) + }) + + it("should throw an error when retrieving a non-existent key", async () => { + const accountId = "0x456" as AccountId + const password = "testpassword" + + mockStore.get.mockResolvedValue({ keystore: {} }) + + await expect( + pkManager.retrieveDecryptedKey(password, accountId), + ).rejects.toThrow("Key not found") + }) + + it("should throw an error when decrypting with an incorrect password", async () => { + const accountId = "0x789" as AccountId + const privateKey = "0x9876543210fedcba" + const correctPassword = "correctpassword" + const incorrectPassword = "wrongpassword" + + // Store the key with the correct password + await pkManager.storeEncryptedKey(privateKey, correctPassword, accountId) + + // Mock the get method to return the stored data + mockStore.get.mockResolvedValue({ + keystore: { + [accountId]: await pkManager["encryptKey"](privateKey, correctPassword), + }, + }) + + // Attempt to retrieve with incorrect password + await expect( + pkManager.retrieveDecryptedKey(incorrectPassword, accountId), + ).rejects.toThrow() + }) + + it("should use different salt and nonce for each encryption", async () => { + const accountId1 = "0x111" as AccountId + const accountId2 = "0x222" as AccountId + const key = "0x1234abcd" + const password = "samepassword" + + let storedData: IPKStore = { keystore: {} } + + mockStore.set.mockImplementation(async (value: IPKStore) => { + storedData = { + keystore: { + ...storedData.keystore, + ...value.keystore, + }, + } + }) + + await pkManager.storeEncryptedKey(key, password, accountId1) + mockStore.get.mockResolvedValue(storedData) + + await pkManager.storeEncryptedKey(key, password, accountId2) + + const encryptedData1 = storedData.keystore[accountId1] + const encryptedData2 = storedData.keystore[accountId2] + + expect(encryptedData1).toBeDefined() + expect(encryptedData2).toBeDefined() + expect(encryptedData1.salt).not.toBe(encryptedData2.salt) + expect(encryptedData1.nonce).not.toBe(encryptedData2.nonce) + expect(encryptedData1.encryptedKey).not.toBe(encryptedData2.encryptedKey) + }) +}) diff --git a/packages/extension/src/shared/accountImport/pkManager/PKManager.ts b/packages/extension/src/shared/accountImport/pkManager/PKManager.ts new file mode 100644 index 000000000..d52a3dd2d --- /dev/null +++ b/packages/extension/src/shared/accountImport/pkManager/PKManager.ts @@ -0,0 +1,98 @@ +import { scryptAsync } from "@noble/hashes/scrypt" +import { xchacha20poly1305 } from "@noble/ciphers/chacha" +import type { EncryptedPKData, IPKStore } from "../types" +import type { IPKManager } from "./IPKManager" +import type { AccountId } from "../../wallet.model" +import { + randomBytes, + bytesToHex, + utf8ToBytes, + hexToBytes, +} from "@noble/hashes/utils" +import type { Hex } from "@argent/x-shared" +import { hexSchema } from "@argent/x-shared" +import type { IObjectStore } from "../../storage/__new/interface" +import { encode } from "starknet" + +/** + * PKManager is responsible for securely storing and retrieving encrypted private keys. + * It uses the scrypt key derivation function and the XChaCha20-Poly1305 cipher for encryption and decryption. + */ +export class PKManager implements IPKManager { + constructor( + private readonly keystore: IObjectStore, + private SCRYPT_N: number, + ) {} + + async storeEncryptedKey( + pk: Hex, + password: string, + accountId: AccountId, + ): Promise { + const encryptedData = await this.encryptKey(pk, password) + const { keystore } = await this.keystore.get() + await this.keystore.set({ + keystore: { + ...keystore, + [accountId]: encryptedData, + }, + }) + } + + async retrieveDecryptedKey( + password: string, + accountId: AccountId, + ): Promise { + const { keystore } = await this.keystore.get() + if (!keystore[accountId]) { + throw new Error("Key not found") + } + + const decrypted = await this.decryptKey(keystore[accountId], password) + return hexSchema.parse(bytesToHex(decrypted)) + } + + // Using XChaCha20-Poly1305 for encryption due to its extended nonce size (192 bits) which reduces the risk of nonce reuse, + // and its performance benefits over AES-GCM for devices without hardware acceleration. + async encryptKey(pk: Hex, password: string): Promise { + const nonce = randomBytes(24) + const salt = randomBytes(16) + const key = await this.generateDerivedKey(password, salt) + const cipher = xchacha20poly1305(key, nonce) + const encrypted = cipher.encrypt(hexToBytes(encode.removeHexPrefix(pk))) + return { + nonce: bytesToHex(nonce), + salt: bytesToHex(salt), + encryptedKey: bytesToHex(encrypted), + } + } + + async decryptKey( + data: EncryptedPKData, + password: string, + ): Promise { + const salt = hexToBytes(data.salt) + const key = await this.generateDerivedKey(password, salt) + const nonce = hexToBytes(data.nonce) + const cipher = xchacha20poly1305(key, nonce) + return cipher.decrypt(hexToBytes(data.encryptedKey)) + } + + async removeKey(accountId: AccountId): Promise { + const { keystore } = await this.keystore.get() + delete keystore[accountId] + await this.keystore.set({ keystore }) + } + + private generateDerivedKey( + password: string, + salt: Uint8Array, + ): Promise { + return scryptAsync(utf8ToBytes(password), salt, { + N: this.SCRYPT_N, + r: 8, + p: 1, + dkLen: 32, + }) + } +} diff --git a/packages/extension/src/shared/accountImport/pkManager/index.ts b/packages/extension/src/shared/accountImport/pkManager/index.ts new file mode 100644 index 000000000..61a598dd7 --- /dev/null +++ b/packages/extension/src/shared/accountImport/pkManager/index.ts @@ -0,0 +1,14 @@ +import { PKManager } from "./PKManager" +import { pkStore } from "./storage" + +const isDev = process.env.NODE_ENV === "development" +const isTest = process.env.NODE_ENV === "test" +const isDevOrTest = isDev || isTest + +// SCRYPT_N is set to 262144 in WalletSingleton.ts for unlocking the wallet, a highly sensitive operation. +// A higher SCRYPT_N value increases the time to unlock the wallet, enhancing security by making brute-force attacks more difficult. +// In contrast, SCRYPT_N is set to 16384 in PKManager for storing and retrieving encrypted private keys. +// This lower value is chosen because accessing encrypted private keys requires the wallet to be already unlocked. +// The value 16384 is chosen to balance security and performance for key storage operations. +const SCRYPT_N = isDevOrTest ? 64 : 16384 +export const pkManager = new PKManager(pkStore, SCRYPT_N) diff --git a/packages/extension/src/shared/accountImport/pkManager/storage.ts b/packages/extension/src/shared/accountImport/pkManager/storage.ts new file mode 100644 index 000000000..636c3e9bd --- /dev/null +++ b/packages/extension/src/shared/accountImport/pkManager/storage.ts @@ -0,0 +1,18 @@ +import { KeyValueStorage } from "../../storage" +import { adaptKeyValue } from "../../storage/__new/keyvalue" +import type { IPKStore } from "../types" + +const keyValueStorage = new KeyValueStorage( + { + keystore: {}, + }, + { + namespace: "service:pkManager", + }, +) + +/** + * @internal + * This storage is intended to be used only by the PKManager for storing encrypted keys. + */ +export const pkStore = adaptKeyValue(keyValueStorage) diff --git a/packages/extension/src/shared/accountImport/service/AccountImportSharedService.test.ts b/packages/extension/src/shared/accountImport/service/AccountImportSharedService.test.ts new file mode 100644 index 000000000..ab0872b33 --- /dev/null +++ b/packages/extension/src/shared/accountImport/service/AccountImportSharedService.test.ts @@ -0,0 +1,354 @@ +import type { Provider } from "starknet" +import { constants, stark } from "starknet" +import type { Mocked } from "vitest" +import { describe, it, expect, vi, beforeEach } from "vitest" +import type { INetworkService } from "../../network/service/INetworkService" +import { AccountImportSharedService } from "./AccountImportSharedService" +import * as providerModule from "../../network" +import { ImportedAccount } from "../account" +import { AccountImportError } from "../types" +import { getMockNetwork } from "../../../../test/network.mock" +import { MockFnRepository } from "../../storage/__new/__test__/mockFunctionImplementation" +import type { INetworkRepo } from "../../network/store" +import { NetworkService } from "../../network/service/NetworkService" +import type { IPKManager } from "../pkManager/IPKManager" +import type { WalletAccount } from "../../wallet.model" +import { SignerType } from "../../wallet.model" +import type { Hex } from "@argent/x-shared" +import { addressSchema, hexSchema } from "@argent/x-shared" +import { getAccountIdentifier } from "../../utils/accountIdentifier" +import type { IAccountService } from "../../account/service/accountService/IAccountService" + +vi.mock("../../network/makeSafeNetworks", () => ({ + makeSafeNetworks: vi.fn().mockImplementation((networks) => networks), +})) + +// Mock dependencies +vi.mock("../../network") +vi.mock("../account") +vi.mock("../../signer/PrivateKeySigner") + +describe("ImportAccountSharedService", () => { + const mockAddress = addressSchema.parse(stark.randomAddress()) + const mockNetworkId = "sepolia-alpha" + const mockPk = "0xabcdef1234567890" as Hex + const mockProvider = { + getClassHashAt: vi.fn(), + } as unknown as Mocked + const mockImportedAccount = { + simulateTransaction: vi.fn(), + } as unknown as Mocked + + const mockAccountService = { + upsert: vi.fn(), + get: vi.fn(), + } as unknown as Mocked + + const mockNetwork = getMockNetwork() + let mockNetworkRepo: MockFnRepository + + let service: AccountImportSharedService + let mockNetworkService: Mocked + let mockPkManager: Mocked + + beforeEach(() => { + vi.resetAllMocks() + mockNetworkRepo = new MockFnRepository() + mockNetworkService = vi.mocked( + new NetworkService(mockNetworkRepo), + ) + mockNetworkService.getById = vi.fn().mockResolvedValue(mockNetwork) + vi.mocked(providerModule.getProvider).mockReturnValue(mockProvider) + vi.mocked(ImportedAccount).mockReturnValue(mockImportedAccount) + + mockPkManager = { + storeEncryptedKey: vi.fn(), + retrieveDecryptedKey: vi.fn(), + removeKey: vi.fn(), + } as Mocked + + service = new AccountImportSharedService( + mockAccountService, + mockNetworkService, + mockPkManager, + ) + }) + + describe("validateImportedAccount", () => { + it("should return ACCOUNT_NOT_FOUND if provider.getClassHashAt throws", async () => { + mockProvider.getClassHashAt.mockRejectedValue(new Error("Not found")) + + const result = await service.validateImport( + mockAddress, + mockPk, + mockNetworkId, + ) + + expect(result).toEqual({ + success: false, + errorType: AccountImportError.ACCOUNT_NOT_FOUND, + }) + }) + + it("should return INVALID_PK if validatePrivateKey throws", async () => { + mockProvider.getClassHashAt.mockResolvedValue("0xclasshash") + mockImportedAccount.simulateTransaction.mockRejectedValue( + new Error("Simulation failed"), + ) + + const result = await service.validateImport( + mockAddress, + mockPk, + mockNetworkId, + ) + + expect(result).toEqual({ + success: false, + errorType: AccountImportError.INVALID_PK, + }) + }) + + it("should return success if everything is valid", async () => { + const mockClassHash = stark.randomAddress() + + mockProvider.getClassHashAt.mockResolvedValue(mockClassHash) + mockImportedAccount.simulateTransaction = vi + .fn() + .mockResolvedValue(undefined) + + const result = await service.validateImport( + mockAddress, + mockPk, + mockNetworkId, + ) + + expect(result).toEqual({ + success: true, + result: { + address: addressSchema.parse(mockAddress), + networkId: mockNetworkId, + classHash: addressSchema.parse(mockClassHash), + pk: hexSchema.parse(mockPk), + }, + }) + }) + }) + + describe("validatePrivateKey", () => { + it("should try ETH transfer first", async () => { + mockImportedAccount.simulateTransaction = vi + .fn() + .mockResolvedValue(undefined) + + await service["validatePrivateKey"](mockImportedAccount as any) + + expect(mockImportedAccount.simulateTransaction).toHaveBeenNthCalledWith( + 1, + [ + { + contractAddress: + "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + entrypoint: "transfer", + calldata: [ + "2087021424722619777119509474943472645767659996348769578120564519014510906823", + "1", + "0", + ], + type: "INVOKE_FUNCTION", + }, + ], + { + skipValidate: false, + version: constants.TRANSACTION_VERSION.V1, + }, + ) + }) + + it("should try STRK transfer if ETH transfer fails", async () => { + mockImportedAccount.simulateTransaction = vi + .fn() + .mockRejectedValueOnce(new Error("ETH transfer failed")) + .mockResolvedValueOnce(undefined) + + await service["validatePrivateKey"](mockImportedAccount as any) + + expect(mockImportedAccount.simulateTransaction).toBeCalledTimes(2) + + expect(mockImportedAccount.simulateTransaction).toHaveBeenNthCalledWith( + 2, + [ + { + contractAddress: + "0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d", + entrypoint: "transfer", + calldata: [ + "2009894490435840142178314390393166646092438090257831307886760648929397478285", + "1", + "0", + ], + type: "INVOKE_FUNCTION", + }, + ], + { + skipValidate: false, + version: constants.TRANSACTION_VERSION.V3, + }, + ) + }) + + it("should throw if both ETH and STRK transfers fail", async () => { + mockImportedAccount.simulateTransaction + .mockRejectedValueOnce(new Error("ETH transfer failed")) + .mockRejectedValueOnce(new Error("STRK transfer failed")) + + await expect( + service["validatePrivateKey"](mockImportedAccount as any), + ).rejects.toThrow("STRK transfer failed") + }) + + it("should use the correct transaction version for ETH transfer", async () => { + mockImportedAccount.simulateTransaction = vi + .fn() + .mockResolvedValue(undefined) + + await service["validatePrivateKey"](mockImportedAccount as any) + + expect(mockImportedAccount.simulateTransaction).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + version: constants.TRANSACTION_VERSION.V1, + }), + ) + }) + + it("should use the correct transaction version for STRK transfer", async () => { + mockImportedAccount.simulateTransaction = vi + .fn() + .mockRejectedValueOnce(new Error("ETH transfer failed")) + .mockResolvedValueOnce(undefined) + + await service["validatePrivateKey"](mockImportedAccount as any) + + expect(mockImportedAccount.simulateTransaction).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + version: constants.TRANSACTION_VERSION.V3, + }), + ) + }) + }) + + describe("importAccount", () => { + it("should successfully import a valid account", async () => { + const mockValidationResult = { + success: true as const, + result: { + address: addressSchema.parse(mockAddress), + networkId: mockNetworkId, + classHash: addressSchema.parse(stark.randomAddress()), + pk: hexSchema.parse(mockPk), + }, + } + + vi.spyOn(service, "validateImport").mockResolvedValue( + mockValidationResult, + ) + mockPkManager.storeEncryptedKey.mockResolvedValue(undefined) + mockAccountService.get.mockResolvedValue([]) + mockAccountService.upsert.mockResolvedValue(undefined) + + const result = await service.importAccount( + mockValidationResult.result, + "password", + ) + + const signer = { + type: SignerType.PRIVATE_KEY, + derivationPath: "m/0/0/0/0/0", + } + + const id = getAccountIdentifier( + mockValidationResult.result.address, + mockValidationResult.result.networkId, + signer, + ) + + expect(result).toEqual({ + id, + address: mockValidationResult.result.address, + networkId: mockValidationResult.result.networkId, + signer, + name: "Imported Account 1", + type: "imported", + network: mockNetwork, + needsDeploy: false, + classHash: mockValidationResult.result.classHash, + cairoVersion: "1", + }) + expect(mockPkManager.storeEncryptedKey).toHaveBeenCalledWith( + mockPk, + "password", + id, + ) + expect(mockAccountService.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + address: addressSchema.parse(mockAddress), + networkId: mockNetworkId, + }), + ) + }) + + it("should handle multiple imported accounts", async () => { + const mockValidationResult = { + success: true as const, + result: { + address: addressSchema.parse(mockAddress), + networkId: mockNetworkId, + classHash: addressSchema.parse(stark.randomAddress()), + pk: hexSchema.parse(mockPk), + }, + } + + vi.spyOn(service, "validateImport").mockResolvedValue( + mockValidationResult, + ) + mockPkManager.storeEncryptedKey.mockResolvedValue(undefined) + mockAccountService.get.mockResolvedValue([ + {} as WalletAccount, + {} as WalletAccount, + ]) + mockAccountService.upsert.mockResolvedValue(undefined) + + const result = await service.importAccount( + mockValidationResult.result, + "password", + ) + + expect(result.name).toBe("Imported Account 3") + }) + + it("should throw an error if storeEncryptedKey fails", async () => { + const mockValidationResult = { + success: true as const, + result: { + address: addressSchema.parse(mockAddress), + networkId: mockNetworkId, + classHash: addressSchema.parse(stark.randomAddress()), + pk: hexSchema.parse(mockPk), + }, + } + + vi.spyOn(service, "validateImport").mockResolvedValue( + mockValidationResult, + ) + mockPkManager.storeEncryptedKey.mockRejectedValue( + new Error("Failed to store key"), + ) + mockAccountService.get.mockResolvedValue([]) + + await expect( + service.importAccount(mockValidationResult.result, "password"), + ).rejects.toThrow("Failed to store key") + }) + }) +}) diff --git a/packages/extension/src/shared/accountImport/service/AccountImportSharedService.ts b/packages/extension/src/shared/accountImport/service/AccountImportSharedService.ts new file mode 100644 index 000000000..e29b03e23 --- /dev/null +++ b/packages/extension/src/shared/accountImport/service/AccountImportSharedService.ts @@ -0,0 +1,255 @@ +import type { Address, Hex } from "@argent/x-shared" +import { + addressSchema, + ETH_TOKEN_ADDRESS, + hexSchema, + STRK_TOKEN_ADDRESS, + transferCalldataSchema, +} from "@argent/x-shared" +import { getProvider } from "../../network" +import type { INetworkService } from "../../network/service/INetworkService" +import { ImportedAccount } from "../account" +import type { IAccountImportSharedService } from "./IAccountImportSharedService" +import type { ProviderInterface } from "starknet" +import { CallData, constants, num, TransactionType, uint256 } from "starknet" +import { PrivateKeySigner } from "../../signer/PrivateKeySigner" +import type { ImportValidationResult, ValidatedImport } from "../types" +import { AccountImportError } from "../types" +import type { IPKManager } from "../pkManager/IPKManager" +import { getDerivationPathForIndex } from "../../signer/utils" +import type { WalletAccount } from "../../wallet.model" +import { SignerType } from "../../wallet.model" +import { getAccountIdentifier } from "../../utils/accountIdentifier" +import type { IAccountService } from "../../account/service/accountService/IAccountService" +import { ampli } from "../../analytics" +import type { TestnetAccountImportFailedProperties } from "../../../ampli" + +export class AccountImportSharedService implements IAccountImportSharedService { + constructor( + public readonly accountService: IAccountService, + public readonly networkService: Pick, + public readonly pkManager: IPKManager, + ) {} + + async validateImport( + address: Address, + pk: Hex, + networkId: string, + ): Promise { + const network = await this.networkService.getById(networkId) + const provider = getProvider(network) + + let classHash: string + let importedAccount: ImportedAccount + let success = true + let errorType: AccountImportError | undefined + + try { + classHash = await provider.getClassHashAt(address) + } catch { + success = false + errorType = AccountImportError.ACCOUNT_NOT_FOUND + this.trackImportEvent({ success, errorType }, undefined) + return { success, errorType } + } + + try { + importedAccount = this.buildAccount(provider, address, classHash, pk) + } catch { + success = false + errorType = AccountImportError.INVALID_PK + this.trackImportEvent({ success, errorType }, classHash) + return { success, errorType } + } + + if (await this.hasGuardianSigner(importedAccount)) { + success = false + errorType = AccountImportError.HAS_GUARDIAN + this.trackImportEvent({ success, errorType }, classHash) + return { success, errorType } + } + + if (await this.isMultisigAccount(importedAccount)) { + success = false + errorType = AccountImportError.IS_MULTISIG + this.trackImportEvent({ success, errorType }, classHash) + return { success, errorType } + } + + try { + await this.validatePrivateKey(importedAccount) + } catch { + success = false + errorType = AccountImportError.INVALID_PK + this.trackImportEvent({ success, errorType }, classHash) + return { success, errorType } + } + + const result = { + address, + networkId, + classHash: addressSchema.parse(classHash), + pk, + } + + this.trackImportEvent({ success, result }, classHash) + + return { success, result } + } + + // Use 0x0 if the account doesn't exist and classhash can't be retrieved + trackImportEvent(result: ImportValidationResult, classHash = "0x0") { + if (result.success) { + ampli.testnetAccountImportCompleted({ + "imported account class hash": classHash, + }) + } else { + let error: TestnetAccountImportFailedProperties["key import error"] + switch (result.errorType) { + case AccountImportError.ACCOUNT_NOT_FOUND: + error = "account_not_found" + break + case AccountImportError.INVALID_PK: + error = "invalid_key" + break + case AccountImportError.HAS_GUARDIAN: + error = "has_guardian" + break + case AccountImportError.IS_MULTISIG: + error = "is_multisig" + break + } + ampli.testnetAccountImportFailed({ + "key import error": error, + "imported account class hash": classHash, + }) + } + } + + async importAccount(account: ValidatedImport, password: string) { + const { address, networkId, classHash, pk } = account + const allImportedAccounts = await this.accountService.get( + (acc) => acc.type === "imported", + ) + + const network = await this.networkService.getById(networkId) + const derivationPath = getDerivationPathForIndex( + allImportedAccounts.length, + SignerType.PRIVATE_KEY, + "imported", + ) + const accountId = getAccountIdentifier(address, networkId, { + type: SignerType.PRIVATE_KEY, + derivationPath, + }) + + await this.pkManager.storeEncryptedKey(pk, password, accountId) + + const wa: WalletAccount = { + id: accountId, + address, + networkId, + signer: { type: SignerType.PRIVATE_KEY, derivationPath }, + name: `Imported Account ${allImportedAccounts.length + 1}`, + type: "imported", + network, + needsDeploy: false, + classHash, + cairoVersion: "1", // We only support Cairo 1 accounts + } + + await this.accountService.upsert(wa) + + return wa + } + + private buildAccount( + provider: ProviderInterface, + address: Address, + classHash: string, + pk: Hex, + ): ImportedAccount { + const parsedPk = hexSchema.parse(pk) + const pkSigner = new PrivateKeySigner(parsedPk) + return new ImportedAccount(provider, address, pkSigner, "1", classHash) + } + + private async validatePrivateKey(account: ImportedAccount) { + try { + const ethTransferCall = this.buildTransferCall( + ETH_TOKEN_ADDRESS, + BigInt(1), + ) + await account.simulateTransaction([ethTransferCall], { + // Using V1 instead of F1 because braavos skips the validation for F1 and F3 + // even if the skipValidate is false + version: constants.TRANSACTION_VERSION.V1, + skipValidate: false, + }) + } catch { + const strkTransferCall = this.buildTransferCall( + STRK_TOKEN_ADDRESS, + BigInt(1), + ) + await account.simulateTransaction([strkTransferCall], { + version: constants.TRANSACTION_VERSION.V3, + skipValidate: false, + }) + } + } + + private buildTransferCall(tokenAddress: Address, amount: bigint) { + return { + type: TransactionType.INVOKE as const, + contractAddress: tokenAddress, + entrypoint: "transfer", + calldata: CallData.compile( + transferCalldataSchema.parse({ + recipient: tokenAddress, + amount: uint256.bnToUint256(amount), + }), + ), + } + } + + private async hasGuardianSigner(account: ImportedAccount) { + const entrypoints = ["get_guardian"] as const + for (const entrypoint of entrypoints) { + try { + const [res] = await account.callContract({ + contractAddress: account.address, + entrypoint, + }) + + if (num.toBigInt(res) !== 0n) { + return true + } + } catch { + // Ignore errors and try the next entrypoint + } + } + + return false + } + + private async isMultisigAccount(account: ImportedAccount) { + const entrypoints = [ + "get_threshold", // Argent Multisig + "get_multisig_threshold", // Braavos MultiOwner (MOA) + ] as const + for (const entrypoint of entrypoints) { + try { + const [res] = await account.callContract({ + contractAddress: account.address, + entrypoint, + }) + if (num.toBigInt(res) > BigInt(1)) { + return true + } + } catch { + // Ignore errors and try the next entrypoint + } + } + return false + } +} diff --git a/packages/extension/src/shared/accountImport/service/IAccountImportSharedService.ts b/packages/extension/src/shared/accountImport/service/IAccountImportSharedService.ts new file mode 100644 index 000000000..1ca413768 --- /dev/null +++ b/packages/extension/src/shared/accountImport/service/IAccountImportSharedService.ts @@ -0,0 +1,16 @@ +import type { Address, Hex } from "@argent/x-shared" +import type { WalletAccount } from "../../wallet.model" +import type { ValidatedImport, ImportValidationResult } from "../types" + +export interface IAccountImportSharedService { + validateImport: ( + address: Address, + pk: Hex, + networkId: string, + ) => Promise + + importAccount: ( + validatedAccount: ValidatedImport, + password: string, + ) => Promise +} diff --git a/packages/extension/src/shared/accountImport/service/index.ts b/packages/extension/src/shared/accountImport/service/index.ts new file mode 100644 index 000000000..12d91fa45 --- /dev/null +++ b/packages/extension/src/shared/accountImport/service/index.ts @@ -0,0 +1,10 @@ +import { accountService } from "../../account/service" +import { networkService } from "../../network/service" +import { pkManager } from "../pkManager" +import { AccountImportSharedService } from "./AccountImportSharedService" + +export const accountImportSharedService = new AccountImportSharedService( + accountService, + networkService, + pkManager, +) diff --git a/packages/extension/src/shared/accountImport/types.ts b/packages/extension/src/shared/accountImport/types.ts new file mode 100644 index 000000000..f3f52ab9a --- /dev/null +++ b/packages/extension/src/shared/accountImport/types.ts @@ -0,0 +1,46 @@ +import { z } from "zod" +import type { AccountId } from "../wallet.model" +import { addressSchema, hexSchema } from "@argent/x-shared" + +export enum AccountImportError { + ACCOUNT_NOT_FOUND = "The account was not found", + INVALID_PK = "The private key is invalid", + HAS_GUARDIAN = "This account has a guardian signer", + IS_MULTISIG = "The account is a multisig account", +} + +export const importErrorTypeSchema = z.nativeEnum(AccountImportError) + +export const validatedImportSchema = z.object({ + classHash: addressSchema, + address: addressSchema, + pk: hexSchema, + networkId: z.string(), +}) + +export type ValidatedImport = z.infer + +export const importValidationResult = z.union([ + z.object({ + success: z.literal(true), + result: validatedImportSchema, + }), + z.object({ + success: z.literal(false), + errorType: importErrorTypeSchema, + }), +]) + +export type ImportValidationResult = z.infer + +export const encryptedPKDataSchema = z.object({ + encryptedKey: z.string(), + nonce: z.string(), + salt: z.string(), +}) + +export type EncryptedPKData = z.infer + +export interface IPKStore { + keystore: Record +} diff --git a/packages/extension/src/shared/actionQueue/index.ts b/packages/extension/src/shared/actionQueue/index.ts index 48ff62718..eed0c5b70 100644 --- a/packages/extension/src/shared/actionQueue/index.ts +++ b/packages/extension/src/shared/actionQueue/index.ts @@ -1,5 +1,5 @@ import { getActionQueue } from "./queue/getActionQueue" import { actionQueueRepo } from "./store" -import { ActionItem } from "./types" +import type { ActionItem } from "./types" export const actionQueue = getActionQueue(actionQueueRepo) diff --git a/packages/extension/src/shared/actionQueue/queue/IActionQueue.ts b/packages/extension/src/shared/actionQueue/queue/IActionQueue.ts index 7adbd381d..f04b3fe23 100644 --- a/packages/extension/src/shared/actionQueue/queue/IActionQueue.ts +++ b/packages/extension/src/shared/actionQueue/queue/IActionQueue.ts @@ -1,5 +1,5 @@ -import { ActionQueueItemMeta } from "../schema" -import { ExtQueueItem } from "../types" +import type { ActionQueueItemMeta } from "../schema" +import type { ExtQueueItem } from "../types" export interface IActionQueue { get: (hash: string) => Promise | null> diff --git a/packages/extension/src/shared/actionQueue/schema.test.ts b/packages/extension/src/shared/actionQueue/schema.test.ts index 9753e2003..27e10c48a 100644 --- a/packages/extension/src/shared/actionQueue/schema.test.ts +++ b/packages/extension/src/shared/actionQueue/schema.test.ts @@ -16,7 +16,7 @@ describe("actionQueue", () => { actionQueueItemMetaSchema.safeParse({ hash: "0x0123", expires: 0, - icon: "SendIcon", + icon: "SendSecondaryIcon", }).success, ).toBeTruthy() }) diff --git a/packages/extension/src/shared/actionQueue/schema.ts b/packages/extension/src/shared/actionQueue/schema.ts index 3082ada8b..966351af6 100644 --- a/packages/extension/src/shared/actionQueue/schema.ts +++ b/packages/extension/src/shared/actionQueue/schema.ts @@ -1,9 +1,9 @@ import { z } from "zod" // just using types here // eslint-disable-next-line @argent/local/code-import-patterns -import { iconDeprecatedKeysSchema } from "@argent/x-ui" +import { iconKeysSchema } from "@argent/x-ui" import { simulateAndReviewSchema } from "@argent/x-shared/simulation" -import { addressSchema } from "@argent/x-shared" +import { addressSchema, investmentMetaSchema } from "@argent/x-shared" export const actionHashSchema = z.string() export type ActionHash = z.infer @@ -27,8 +27,9 @@ export const actionQueueItemMetaSchema = z.object({ title: z.string().optional(), shortTitle: z.string().optional(), subtitle: z.string().optional(), - icon: iconDeprecatedKeysSchema.optional(), + icon: iconKeysSchema.optional(), transactionReview: simulateAndReviewSchema.optional(), + investment: investmentMetaSchema.optional(), }) export type ActionQueueItemMeta = z.infer diff --git a/packages/extension/src/shared/actionQueue/service/IActionService.ts b/packages/extension/src/shared/actionQueue/service/IActionService.ts index 5fdc92152..1deb50341 100644 --- a/packages/extension/src/shared/actionQueue/service/IActionService.ts +++ b/packages/extension/src/shared/actionQueue/service/IActionService.ts @@ -3,7 +3,7 @@ import type { MessageType } from "../../messages" import type { ActionHash, ActionItemExtra } from "../schema" /** Extract the 'data' type of each item in the union if it exists */ -type ExtractDataType = Type extends { data: any } ? Type["data"] : never +type ExtractDataType = Type extends { data?: any } ? Type["data"] : never /** A union of the 'data' part of each MessageType */ type MessageDataType = ExtractDataType diff --git a/packages/extension/src/shared/actionQueue/types.ts b/packages/extension/src/shared/actionQueue/types.ts index e96d2de60..7b404982e 100644 --- a/packages/extension/src/shared/actionQueue/types.ts +++ b/packages/extension/src/shared/actionQueue/types.ts @@ -7,12 +7,12 @@ import type { } from "starknet" import { z } from "zod" -import { Network } from "../network" -import { TransactionMeta } from "../transactions" -import { BaseWalletAccount } from "../wallet.model" -import { ActionQueueItem } from "./schema" -import { SignMessageOptions } from "../messages/ActionMessage" -import { TypedData } from "@starknet-io/types-js" +import type { Network } from "../network" +import type { TransactionMeta } from "../transactions" +import type { BaseWalletAccount } from "../wallet.model" +import type { ActionQueueItem } from "./schema" +import type { SignMessageOptions } from "../messages/ActionMessage" +import type { TypedData } from "@starknet-io/types-js" export type ExtQueueItem = ActionQueueItem & T diff --git a/packages/extension/src/shared/activity/__fixtures__/activities.json b/packages/extension/src/shared/activity/__fixtures__/activities.json index aab6b241c..7a2ef6176 100644 --- a/packages/extension/src/shared/activity/__fixtures__/activities.json +++ b/packages/extension/src/shared/activity/__fixtures__/activities.json @@ -2247,5 +2247,106 @@ "counterpartyNetwork": "starknet" }, "network": "starknet" + }, + { + "compositeId": "56cad3b8e6d02ca98e15a85e847cb3f930fdb7f43ba5993fa802432599f59619", + "id": "df74ecd9-59b9-4150-846f-2a46047e2b06", + "status": "success", + "wallet": "0x5f1f0a38429dcab9ffd8a786c0d827e84c1cbd8f60243e6d25d066a13af4a25", + "txSender": "0x5f1f0a38429dcab9ffd8a786c0d827e84c1cbd8f60243e6d25d066a13af4a25", + "source": "transaction-monitor", + "type": "security", + "group": "security", + "submitted": 1697464096000, + "lastModified": 1697487786473, + "transaction": { + "network": "starknet", + "hash": "0x0581432e61701a0529962584274ae624361141a592a6bab065dd7ec681524133", + "status": "pending", + "transactionIndex": 2 + }, + "transferSummary": [], + "transfers": [ + { + "type": "gasFee", + "leg": "debit", + "counterparty": "0x1176a1bd84444c89232ec27754698e5d2e7e1a7f1539f12027f28b23ec9f3d8", + "asset": { + "type": "token", + "tokenAddress": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + "amount": "76195966985502", + "fiatAmount": { + "currency": "USD", + "currencyAmount": 0.198 + } + }, + "counterpartyNetwork": "starknet" + } + ], + "fees": [ + { + "type": "gas", + "to": "0x1176a1bd84444c89232ec27754698e5d2e7e1a7f1539f12027f28b23ec9f3d8", + "actualFee": { + "type": "token", + "tokenAddress": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + "amount": "76195966985502", + "fiatAmount": { + "currency": "USD", + "currencyAmount": 0.198 + } + } + } + ], + "relatedAddresses": [], + "networkDetails": { + "ethereumNetwork": "sepolia", + "chainId": "SEPOLIA" + }, + "multisigDetails": { + "signers": [ + "0x07de7b34da32652e524fffe0ed5713b1642524aeea6cb1c2305ff699bf04329f" + ] + }, + "actions": [ + { + "name": "account_multisig_replace_signer", + "properties": [ + { + "type": "address", + "label": "account_multisig_replace_signer_removed_signer", + "address": "0x07de7b34da32652e524fffe0ed5713b1642524aeea6cb1c2305ff699bf04329f" + }, + { + "type": "address", + "label": "account_multisig_replace_signer_added_signer", + "address": "0x04062f88b6079d78d8ccba69c2e8dc32df7adecdc93ff73b196fc9fc26f04993" + } + ], + "defaultProperties": [ + { + "type": "address", + "label": "default_contract", + "address": "0x5f1f0a38429dcab9ffd8a786c0d827e84c1cbd8f60243e6d25d066a13af4a25", + "verified": false + }, + { + "type": "calldata", + "label": "default_call", + "entrypoint": "replace_signer", + "calldata": [ + "0x7de7b34da32652e524fffe0ed5713b1642524aeea6cb1c2305ff699bf04329f", + "0x4062f88b6079d78d8ccba69c2e8dc32df7adecdc93ff73b196fc9fc26f04993" + ] + } + ] + } + ], + "details": { + "action": "multisigConfigurationUpdated", + "type": "security" + }, + "triggeredBalanceUpdate": false, + "network": "starknet" } ] diff --git a/packages/extension/src/shared/activity/index.ts b/packages/extension/src/shared/activity/index.ts deleted file mode 100644 index 28ce5d693..000000000 --- a/packages/extension/src/shared/activity/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { isFeatureEnabled } from "@argent/x-shared" - -export const isActivityV2FeatureEnabled = isFeatureEnabled( - process.env.FEATURE_ACTIVITY_V2, -) diff --git a/packages/extension/src/shared/activity/utils/transform/__fixtures__/tokensByNetwork.ts b/packages/extension/src/shared/activity/utils/transform/__fixtures__/tokensByNetwork.ts index 027a01875..577d27fc5 100644 --- a/packages/extension/src/shared/activity/utils/transform/__fixtures__/tokensByNetwork.ts +++ b/packages/extension/src/shared/activity/utils/transform/__fixtures__/tokensByNetwork.ts @@ -1,4 +1,4 @@ -import { Token } from "../../../../token/__new/types/token.model" +import type { Token } from "../../../../token/__new/types/token.model" import { parsedDefaultTokens } from "../../../../token/__new/utils" export const tokensByNetwork: Token[] = parsedDefaultTokens.filter( diff --git a/packages/extension/src/shared/activity/utils/transform/activity/buildActivitySummary.ts b/packages/extension/src/shared/activity/utils/transform/activity/buildActivitySummary.ts index 851944f6e..d51dec68d 100644 --- a/packages/extension/src/shared/activity/utils/transform/activity/buildActivitySummary.ts +++ b/packages/extension/src/shared/activity/utils/transform/activity/buildActivitySummary.ts @@ -1,14 +1,12 @@ -import { - ActivitySummary, - activitySummarySchema, -} from "@argent/x-shared/simulation" +import type { ActivitySummary } from "@argent/x-shared/simulation" +import { activitySummarySchema } from "@argent/x-shared/simulation" import { isSwapTransaction, isTokenApproveTransaction, isTokenMintTransaction, isTokenTransferTransaction, } from "../is" -import { TransformedTransaction } from "./../type" +import type { TransformedTransaction } from "./../type" import { addressSchema } from "@argent/x-shared" /** diff --git a/packages/extension/src/shared/activity/utils/transform/explorerTransaction/dappExplorerTransaction.ts b/packages/extension/src/shared/activity/utils/transform/explorerTransaction/dappExplorerTransaction.ts deleted file mode 100644 index 27211411a..000000000 --- a/packages/extension/src/shared/activity/utils/transform/explorerTransaction/dappExplorerTransaction.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { - IExplorerTransaction, - IExplorerTransactionCall, - IExplorerTransactionEvent, -} from "../../../../explorer/type" -import { getKnownDappForContractAddress } from "../../../../knownDapps" - -/** - * Crude test if any event or call `address` or parameter value is a known dapp - */ - -export const getKnownDappForExplorerTransaction = ( - explorerTransaction: IExplorerTransaction, - network?: string, -) => { - const { calls, events } = explorerTransaction - const eventsAndCalls: Array< - IExplorerTransactionEvent | IExplorerTransactionCall - > = calls ? [...events, ...calls] : events - for (const eventsOrCall of eventsAndCalls) { - const { address, parameters } = eventsOrCall - const dapp = getKnownDappForContractAddress(address, network) - if (dapp) { - return { dapp, dappContractAddress: address } - } - - if (!parameters) { - continue - } - - for (const { value } of parameters) { - const dapp = getKnownDappForContractAddress(value, network) - if (dapp) { - return { dapp, dappContractAddress: value } - } - } - } -} diff --git a/packages/extension/src/shared/activity/utils/transform/explorerTransaction/dappTransaction.ts b/packages/extension/src/shared/activity/utils/transform/explorerTransaction/dappTransaction.ts deleted file mode 100644 index 371a57997..000000000 --- a/packages/extension/src/shared/activity/utils/transform/explorerTransaction/dappTransaction.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { getKnownDappForContractAddress } from "../../../../knownDapps" -import { Transaction } from "../../../../transactions" -import { ActivityTransaction } from "../type" -import { getCallsFromTransaction } from "../transaction/getCallsFromTransaction" - -/** - * Crude test if any call `contractAddress` is a known dapp - */ - -export const getKnownDappForTransaction = ( - transaction: ActivityTransaction | Transaction, - network?: string, -) => { - const calls = getCallsFromTransaction(transaction) - for (const call of calls) { - const dapp = getKnownDappForContractAddress(call.contractAddress, network) - if (dapp) { - return { dapp, dappContractAddress: call.contractAddress } - } - } -} diff --git a/packages/extension/src/shared/activity/utils/transform/explorerTransaction/fingerprintExplorerTransaction.test.ts b/packages/extension/src/shared/activity/utils/transform/explorerTransaction/fingerprintExplorerTransaction.test.ts index f54dca8c1..64214701a 100644 --- a/packages/extension/src/shared/activity/utils/transform/explorerTransaction/fingerprintExplorerTransaction.test.ts +++ b/packages/extension/src/shared/activity/utils/transform/explorerTransaction/fingerprintExplorerTransaction.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "vitest" -import { IExplorerTransaction } from "../../../../explorer/type" +import type { IExplorerTransaction } from "../../../../explorer/type" import { fingerprintExplorerTransaction } from "./fingerprintExplorerTransaction" import { accountCreated, diff --git a/packages/extension/src/shared/activity/utils/transform/explorerTransaction/fingerprintExplorerTransaction.ts b/packages/extension/src/shared/activity/utils/transform/explorerTransaction/fingerprintExplorerTransaction.ts index 06386f2a8..485023d7f 100644 --- a/packages/extension/src/shared/activity/utils/transform/explorerTransaction/fingerprintExplorerTransaction.ts +++ b/packages/extension/src/shared/activity/utils/transform/explorerTransaction/fingerprintExplorerTransaction.ts @@ -3,7 +3,7 @@ * * This is used to match known transaction types in the transformer */ -import { IExplorerTransaction } from "../../../../explorer/type" +import type { IExplorerTransaction } from "../../../../explorer/type" export const getPreExecutionEventNames = ( explorerTransaction: IExplorerTransaction, diff --git a/packages/extension/src/shared/activity/utils/transform/explorerTransaction/getActualFee.test.ts b/packages/extension/src/shared/activity/utils/transform/explorerTransaction/getActualFee.test.ts index 59bb643fd..58c1b3369 100644 --- a/packages/extension/src/shared/activity/utils/transform/explorerTransaction/getActualFee.test.ts +++ b/packages/extension/src/shared/activity/utils/transform/explorerTransaction/getActualFee.test.ts @@ -1,6 +1,6 @@ import { getActualFee } from "./getActualFee" import { describe, it, expect } from "vitest" -import { IExplorerTransaction } from "../../../../explorer/type" +import type { IExplorerTransaction } from "../../../../explorer/type" describe("getActualFee", () => { it("should return correct fee, given actualFee as a string", () => { diff --git a/packages/extension/src/shared/activity/utils/transform/explorerTransaction/getActualFee.ts b/packages/extension/src/shared/activity/utils/transform/explorerTransaction/getActualFee.ts index e3102e5b7..182ec9b80 100644 --- a/packages/extension/src/shared/activity/utils/transform/explorerTransaction/getActualFee.ts +++ b/packages/extension/src/shared/activity/utils/transform/explorerTransaction/getActualFee.ts @@ -1,7 +1,5 @@ -import { - explorerTransactionActualFeeSchema, - IExplorerTransaction, -} from "../../../../explorer/type" +import type { IExplorerTransaction } from "../../../../explorer/type" +import { explorerTransactionActualFeeSchema } from "../../../../explorer/type" export function getActualFee({ actualFee }: IExplorerTransaction) { let actualFeeAmount = "0x0" diff --git a/packages/extension/src/shared/activity/utils/transform/explorerTransaction/getParameter.ts b/packages/extension/src/shared/activity/utils/transform/explorerTransaction/getParameter.ts index 025149f8d..b5dee5f43 100644 --- a/packages/extension/src/shared/activity/utils/transform/explorerTransaction/getParameter.ts +++ b/packages/extension/src/shared/activity/utils/transform/explorerTransaction/getParameter.ts @@ -1,6 +1,6 @@ import { isArray } from "lodash-es" -import { IExplorerTransactionParameters } from "../../../../explorer/type" +import type { IExplorerTransactionParameters } from "../../../../explorer/type" export const getParameter = ( parameters: IExplorerTransactionParameters[] | undefined, diff --git a/packages/extension/src/shared/activity/utils/transform/explorerTransaction/transformExplorerTransaction.test.ts b/packages/extension/src/shared/activity/utils/transform/explorerTransaction/transformExplorerTransaction.test.ts index 81865a046..0b5ccace7 100644 --- a/packages/extension/src/shared/activity/utils/transform/explorerTransaction/transformExplorerTransaction.test.ts +++ b/packages/extension/src/shared/activity/utils/transform/explorerTransaction/transformExplorerTransaction.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "vitest" -import { IExplorerTransaction } from "../../../../explorer/type" +import type { IExplorerTransaction } from "../../../../explorer/type" import { nftContractAddresses } from "../__fixtures__/nftContractAddresses" import { tokensByNetwork } from "../__fixtures__/tokensByNetwork" import { transformExplorerTransaction } from "./transformExplorerTransaction" @@ -54,7 +54,7 @@ describe("transformExplorerTransaction", () => { "decimals": 18, "iconUrl": "https://dv3jj1unlp2jl.cloudfront.net/128/color/eth.png", "id": 1, - "name": "Ether", + "name": "Ethereum", "network": "sepolia-alpha", "networkId": "sepolia-alpha", "showAlways": true, @@ -87,7 +87,7 @@ describe("transformExplorerTransaction", () => { "decimals": 18, "iconUrl": "https://dv3jj1unlp2jl.cloudfront.net/128/color/eth.png", "id": 1, - "name": "Ether", + "name": "Ethereum", "network": "sepolia-alpha", "networkId": "sepolia-alpha", "showAlways": true, @@ -120,7 +120,7 @@ describe("transformExplorerTransaction", () => { "decimals": 18, "iconUrl": "https://dv3jj1unlp2jl.cloudfront.net/128/color/eth.png", "id": 1, - "name": "Ether", + "name": "Ethereum", "network": "sepolia-alpha", "networkId": "sepolia-alpha", "showAlways": true, @@ -153,7 +153,7 @@ describe("transformExplorerTransaction", () => { "decimals": 18, "iconUrl": "https://dv3jj1unlp2jl.cloudfront.net/128/color/eth.png", "id": 1, - "name": "Ether", + "name": "Ethereum", "network": "sepolia-alpha", "networkId": "sepolia-alpha", "showAlways": true, @@ -185,7 +185,7 @@ describe("transformExplorerTransaction", () => { "decimals": 18, "iconUrl": "https://dv3jj1unlp2jl.cloudfront.net/128/color/eth.png", "id": 1, - "name": "Ether", + "name": "Ethereum", "network": "mainnet-alpha", "networkId": "mainnet-alpha", "showAlways": true, @@ -224,16 +224,6 @@ describe("transformExplorerTransaction", () => { "action": "APPROVE", "actualFee": "78640023328647", "amount": "115792089237316195423570985008687907853269984665640564039457584007913129639935", - "dapp": { - "hosts": [ - "aspect.co", - "testnet.aspect.co", - ], - "icon": "https://aspect.co/img/company/logo512.png", - "id": "aspect-co", - "title": "Aspect", - }, - "dappContractAddress": "0x2a92f0f860bf7c63fb9ef42cff4137006b309e0e6e1484e42d0b5511959414d", "date": "2022-09-07T08:56:31.000Z", "displayName": "Approve", "entity": "TOKEN", @@ -244,7 +234,7 @@ describe("transformExplorerTransaction", () => { "decimals": 18, "iconUrl": "https://dv3jj1unlp2jl.cloudfront.net/128/color/eth.png", "id": 1, - "name": "Ether", + "name": "Ethereum", "network": "mainnet-alpha", "networkId": "mainnet-alpha", "showAlways": true, @@ -440,35 +430,12 @@ describe("transformExplorerTransaction", () => { }), ).toMatchInlineSnapshot(` { - "action": "SWAP", + "action": "UNKNOWN", "actualFee": "49915215635677", - "dapp": { - "hosts": [ - "testnet.app.alpharoad.fi", - ], - "id": "alpharoad-fi", - "title": "Alpha Road", - }, - "dappContractAddress": "0x4aec73f0611a9be0524e7ef21ab1679bdf9c97dc7d72614f15373d431226b6a", "date": "2022-08-18T11:50:28.000Z", - "displayName": "Sold ETH for unknown", - "entity": "TOKEN", - "fromAmount": "211522156930263", - "fromToken": { - "address": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", - "decimals": 18, - "iconUrl": "https://dv3jj1unlp2jl.cloudfront.net/128/color/eth.png", - "id": 1, - "name": "Ether", - "network": "sepolia-alpha", - "networkId": "sepolia-alpha", - "showAlways": true, - "symbol": "ETH", - }, - "fromTokenAddress": "0x49d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + "displayName": "Approve and swap exact tokens for tokens", + "entity": "UNKNOWN", "maxFee": "70744313503497", - "toAmount": "55785188096947612154", - "toTokenAddress": "0x72df4dc5b6c4df72e4288857317caf2ce9da166ab8719ab8306516a2fddfff7", } `) expect( @@ -479,35 +446,12 @@ describe("transformExplorerTransaction", () => { }), ).toMatchInlineSnapshot(` { - "action": "SWAP", + "action": "UNKNOWN", "actualFee": "36750992760053", - "dapp": { - "hosts": [ - "app.testnet.jediswap.xyz", - ], - "id": "jediswap-xyz", - "title": "JediSwap", - }, - "dappContractAddress": "0x12b063b60553c91ed237d8905dff412fba830c5716b17821063176c6c073341", "date": "2022-08-18T11:50:28.000Z", - "displayName": "Sold ETH for unknown", - "entity": "TOKEN", - "fromAmount": "1000000000000000", - "fromToken": { - "address": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", - "decimals": 18, - "iconUrl": "https://dv3jj1unlp2jl.cloudfront.net/128/color/eth.png", - "id": 1, - "name": "Ether", - "network": "sepolia-alpha", - "networkId": "sepolia-alpha", - "showAlways": true, - "symbol": "ETH", - }, - "fromTokenAddress": "0x49d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + "displayName": "Approve and swap exact tokens for tokens", + "entity": "UNKNOWN", "maxFee": "55126489140079", - "toAmount": "9883", - "toTokenAddress": "0x5a643907b9a4bc6a55e9069c4fd5fd1f5c79a22470690f75556c4736e34426", } `) expect( @@ -518,36 +462,12 @@ describe("transformExplorerTransaction", () => { }), ).toMatchInlineSnapshot(` { - "action": "SWAP", + "action": "UNKNOWN", "actualFee": "35172000035172", - "dapp": { - "hosts": [ - "www.myswap.xyz", - ], - "icon": "https://www.myswap.xyz/favicon.ico", - "id": "myswap-xyz", - "title": "mySwap", - }, - "dappContractAddress": "0x18a439bcbb1b3535a6145c1dc9bc6366267d923f60a84bd0c7618f33c81d334", "date": "2022-08-18T11:50:28.000Z", - "displayName": "Sold ETH for unknown", - "entity": "TOKEN", - "fromAmount": "100000000000000", - "fromToken": { - "address": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", - "decimals": 18, - "iconUrl": "https://dv3jj1unlp2jl.cloudfront.net/128/color/eth.png", - "id": 1, - "name": "Ether", - "network": "sepolia-alpha", - "networkId": "sepolia-alpha", - "showAlways": true, - "symbol": "ETH", - }, - "fromTokenAddress": "0x49d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + "displayName": "Approve", + "entity": "UNKNOWN", "maxFee": "52758000052758", - "toAmount": "29852496290917474", - "toTokenAddress": "0x005a643907b9a4bc6a55e9069c4fd5fd1f5c79a22470690f75556c4736e34426", } `) @@ -560,23 +480,15 @@ describe("transformExplorerTransaction", () => { }), ).toMatchInlineSnapshot(` { - "action": "BUY", + "action": "TRANSFER", "actualFee": "18270500000000", "contractAddress": "0x3090623ea32d932ca1236595076b00702e7d860696faf300ca9eb13bfe0a78c", - "dapp": { - "hosts": [ - "aspect.co", - "testnet.aspect.co", - ], - "icon": "https://aspect.co/img/company/logo512.png", - "id": "aspect-co", - "title": "Aspect", - }, - "dappContractAddress": "0x6fcf30a53fdc33c85ab428d6c481c5d241f1de403009c4e5b66aeaf3edc890", "date": "2022-08-18T11:50:28.000Z", - "displayName": "Buy NFT", + "displayName": "Transfer NFT", "entity": "NFT", + "fromAddress": "0x69b49c2cc8b16e80e86bfc5b0614a59aa8c9b601569c7b80dde04d3f3151b79", "maxFee": "27405750000000", + "toAddress": "0x7e00d496e324876bbc8531f2d9a82bf154d1a04a50218ee74cdd372f75a551a", "tokenId": "3462", } `) @@ -588,21 +500,15 @@ describe("transformExplorerTransaction", () => { }), ).toMatchInlineSnapshot(` { - "action": "BUY", + "action": "TRANSFER", "actualFee": "17222000000000", "contractAddress": "0x3090623ea32d932ca1236595076b00702e7d860696faf300ca9eb13bfe0a78c", - "dapp": { - "hosts": [ - "mintsquare.io", - ], - "id": "mintsquare-io", - "title": "Mint Square", - }, - "dappContractAddress": "0x5bc8cc601c5098e20e9d9d74e86cfb0ec737f6f3ac571914dbe4f74aa249786", "date": "2022-08-18T11:50:28.000Z", - "displayName": "Buy NFT", + "displayName": "Transfer NFT", "entity": "NFT", + "fromAddress": "0x7e00d496e324876bbc8531f2d9a82bf154d1a04a50218ee74cdd372f75a551a", "maxFee": "25832999948334", + "toAddress": "0x69b49c2cc8b16e80e86bfc5b0614a59aa8c9b601569c7b80dde04d3f3151b79", "tokenId": "3462", } `) @@ -615,23 +521,12 @@ describe("transformExplorerTransaction", () => { }), ).toMatchInlineSnapshot(` { - "action": "BUY", + "action": "UNKNOWN", "actualFee": "4557033777702", - "contractAddress": "0x41c4e86a03480313547a04e13fc4d43d7fb7bcb5244fd0cb93f793f304f6124", - "dapp": { - "hosts": [ - "game-goerli.influenceth.io", - ], - "icon": "https://uploads-ssl.webflow.com/60c209ffee9cc9e89d505549/60c8fea5c9d9a170d2f9b5e0_logo-256.png", - "id": "influenceth-io", - "title": "Influence", - }, - "dappContractAddress": "0x4a472fe795cc40e9dc838fe4f1608cb91bf027854d016675ec81e172a2e3599", "date": "2022-08-31T10:55:59.000Z", - "displayName": "Buy NFT", - "entity": "NFT", + "displayName": "Approve", + "entity": "UNKNOWN", "maxFee": "7742757880620", - "tokenId": "9099", } `) @@ -646,17 +541,9 @@ describe("transformExplorerTransaction", () => { { "action": "UNKNOWN", "actualFee": "13666550163999", - "dapp": { - "hosts": [ - "nogamev0-1.netlify.app", - ], - "id": "nogame-app", - "title": "NoGame", - }, - "dappContractAddress": "0x35401b96dc690eda2716068d3b03732d7c18af7c0327787660179108789d84f", "date": "2022-08-18T11:50:28.000Z", "displayName": "Crystal upgrade complete", - "entity": "DAPP", + "entity": "UNKNOWN", "maxFee": "20499825245998", } `) @@ -670,17 +557,9 @@ describe("transformExplorerTransaction", () => { { "action": "UNKNOWN", "actualFee": "17607000123249", - "dapp": { - "hosts": [ - "briq.construction", - ], - "id": "briq-construction", - "title": "briq", - }, - "dappContractAddress": "0x1317354276941f7f799574c73fd8fe53fa3f251084b4c04d88cf601b6bd915e", "date": "2022-08-18T11:50:28.000Z", "displayName": "Assemble", - "entity": "DAPP", + "entity": "UNKNOWN", "maxFee": "26410500211284", } `) diff --git a/packages/extension/src/shared/activity/utils/transform/explorerTransaction/transformExplorerTransaction.ts b/packages/extension/src/shared/activity/utils/transform/explorerTransaction/transformExplorerTransaction.ts index 22d6dabfb..561e480f3 100644 --- a/packages/extension/src/shared/activity/utils/transform/explorerTransaction/transformExplorerTransaction.ts +++ b/packages/extension/src/shared/activity/utils/transform/explorerTransaction/transformExplorerTransaction.ts @@ -1,19 +1,12 @@ -import { IExplorerTransaction } from "../../../../explorer/type" -import { Token } from "../../../../token/__new/types/token.model" -import { TransformedTransaction } from "../type" +import type { IExplorerTransaction } from "../../../../explorer/type" +import type { Token } from "../../../../token/__new/types/token.model" +import type { TransformedTransaction } from "../type" import { fingerprintExplorerTransaction } from "./fingerprintExplorerTransaction" import accountCreateTransformer from "./transformers/accountCreateTransformer" import accountUpgradeTransformer from "./transformers/accountUpgradeTransformer" -import dappAlphaRoadSwapTransformer from "./transformers/dappAlphaRoadSwapTransformer" -import dappAspectBuyNFTTransformer from "./transformers/dappAspectBuyNFTTransformer" -import dappInfluenceMintTransformer from "./transformers/dappInfluenceMintTransformer" -import dappJediswapSwapTransformer from "./transformers/dappJediswapSwapTransformer" -import dappMintSquareBuyNFTTransformer from "./transformers/dappMintSquareBuyNFTTransformer" -import dappMySwapSwapTransformer from "./transformers/dappMySwapSwapTransformer" import dateTransformer from "./transformers/dateTransformer" import defaultDisplayNameTransformer from "./transformers/defaultDisplayNameTransformer" import feesTransformer from "./transformers/feesTransformer" -import knownDappTransformer from "./transformers/knownDappTransformer" import knownNftTransformer from "./transformers/knownNftTransformer" import postSwapTransformer from "./transformers/postSwapTransformer" import postTransferTransformer from "./transformers/postTransferTransformer" @@ -26,7 +19,6 @@ const preTransformers = [ dateTransformer, defaultDisplayNameTransformer, feesTransformer, - knownDappTransformer, ] /** all are executed until one returns */ @@ -35,12 +27,6 @@ const preTransformers = [ const mainTransformers = [ accountCreateTransformer, accountUpgradeTransformer, - dappAlphaRoadSwapTransformer, - dappAspectBuyNFTTransformer, - dappInfluenceMintTransformer, - dappJediswapSwapTransformer, - dappMintSquareBuyNFTTransformer, - dappMySwapSwapTransformer, knownNftTransformer, tokenMintTransformer, tokenTransferTransformer, @@ -124,7 +110,7 @@ export const transformExplorerTransaction = ({ } } return result - } catch (e) { + } catch { // don't throw on parsing error, UI will fallback to default } } diff --git a/packages/extension/src/shared/activity/utils/transform/explorerTransaction/transformers/accountCreateTransformer.ts b/packages/extension/src/shared/activity/utils/transform/explorerTransaction/transformers/accountCreateTransformer.ts index 4cf3a6887..f784710da 100644 --- a/packages/extension/src/shared/activity/utils/transform/explorerTransaction/transformers/accountCreateTransformer.ts +++ b/packages/extension/src/shared/activity/utils/transform/explorerTransaction/transformers/accountCreateTransformer.ts @@ -1,4 +1,4 @@ -import { IExplorerTransactionTransformer } from "./type" +import type { IExplorerTransactionTransformer } from "./type" import { getActualFee } from "../getActualFee" export default function ({ diff --git a/packages/extension/src/shared/activity/utils/transform/explorerTransaction/transformers/accountUpgradeTransformer.ts b/packages/extension/src/shared/activity/utils/transform/explorerTransaction/transformers/accountUpgradeTransformer.ts index f5d21cbf7..d9f8dfeb6 100644 --- a/packages/extension/src/shared/activity/utils/transform/explorerTransaction/transformers/accountUpgradeTransformer.ts +++ b/packages/extension/src/shared/activity/utils/transform/explorerTransaction/transformers/accountUpgradeTransformer.ts @@ -1,4 +1,4 @@ -import { IExplorerTransactionTransformer } from "./type" +import type { IExplorerTransactionTransformer } from "./type" export default function ({ result, diff --git a/packages/extension/src/shared/activity/utils/transform/explorerTransaction/transformers/dappAlphaRoadSwapTransformer.ts b/packages/extension/src/shared/activity/utils/transform/explorerTransaction/transformers/dappAlphaRoadSwapTransformer.ts deleted file mode 100644 index 517ff2f21..000000000 --- a/packages/extension/src/shared/activity/utils/transform/explorerTransaction/transformers/dappAlphaRoadSwapTransformer.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { SwapTransaction } from "../../type" -import { getEntityWithName } from "../getEntityWithName" -import { getParameter } from "../getParameter" -import { IExplorerTransactionTransformer } from "./type" - -/** adds Alpha Road Swap data */ - -export default function ({ - explorerTransaction, - result, - fingerprint, -}: IExplorerTransactionTransformer) { - if ( - result.dapp?.id === "alpharoad-fi" && - fingerprint === - "events[Approval,Transfer,Transfer,Swap] calls[approve,swapExactTokensForTokens]" - ) { - const { events, calls } = explorerTransaction - const event = getEntityWithName(events, "Swap") - const call = getEntityWithName(calls, "swapExactTokensForTokens") - if (event && call && event.parameters) { - const action = "SWAP" - const entity = "TOKEN" - const parameters = event.parameters - const dappContractAddress = call.address - const fromTokenAddress = getParameter(parameters, "token_from_address") - const toTokenAddress = getParameter(parameters, "token_to_address") - const fromAmount = getParameter(parameters, "amount_from") - const toAmount = getParameter(parameters, "amount_to") - result = { - ...result, - action, - entity, - dappContractAddress, - fromTokenAddress, - toTokenAddress, - fromAmount, - toAmount, - } as SwapTransaction - return result - } - } -} diff --git a/packages/extension/src/shared/activity/utils/transform/explorerTransaction/transformers/dappAspectBuyNFTTransformer.ts b/packages/extension/src/shared/activity/utils/transform/explorerTransaction/transformers/dappAspectBuyNFTTransformer.ts deleted file mode 100644 index d36b9df2b..000000000 --- a/packages/extension/src/shared/activity/utils/transform/explorerTransaction/transformers/dappAspectBuyNFTTransformer.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { NFTTransaction } from "../../type" -import { getEntityWithName } from "../getEntityWithName" -import { getParameter } from "../getParameter" -import { IExplorerTransactionTransformer } from "./type" - -/** Aspect buy NFT */ - -export default function ({ - explorerTransaction, - result, - fingerprint, -}: IExplorerTransactionTransformer) { - if ( - result.dapp?.id === "aspect-co" && - fingerprint === - "events[Approval,Approval,Transfer,Transfer,Transfer] calls[approve]" - ) { - const { calls, events } = explorerTransaction - const call = getEntityWithName(calls, "approve") - if (call) { - const action = "BUY" - const entity = "NFT" - const displayName = "Buy NFT" - const contractAddress = events[2].address - const tokenId = getParameter(events[2].parameters ?? undefined, "value") - result = { - ...result, - action, - entity, - displayName, - contractAddress, - tokenId, - } as NFTTransaction - - return result - } - } -} diff --git a/packages/extension/src/shared/activity/utils/transform/explorerTransaction/transformers/dappInfluenceMintTransformer.ts b/packages/extension/src/shared/activity/utils/transform/explorerTransaction/transformers/dappInfluenceMintTransformer.ts deleted file mode 100644 index a574c8df2..000000000 --- a/packages/extension/src/shared/activity/utils/transform/explorerTransaction/transformers/dappInfluenceMintTransformer.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { NFTTransaction } from "../../type" -import { getEntityWithName } from "../getEntityWithName" -import { getParameter } from "../getParameter" -import { IExplorerTransactionTransformer } from "./type" - -/** adds Influence NFT purcahse */ - -export default function ({ - explorerTransaction, - result, - fingerprint, -}: IExplorerTransactionTransformer) { - if ( - result.dapp?.id === "influenceth-io" && - fingerprint === "events[Approval,Transfer,Transfer] calls[approve]" - ) { - const { calls, events } = explorerTransaction - const call = getEntityWithName(calls, "approve") - if (call) { - const action = "BUY" - const entity = "NFT" - const displayName = "Buy NFT" - const dappContractAddress = getParameter(call.parameters, "spender") - const contractAddress = events[2].address - const tokenId = getParameter(events[2].parameters ?? undefined, "value") - result = { - ...result, - action, - entity, - displayName, - dappContractAddress, - contractAddress, - tokenId, - } as NFTTransaction - return result - } - } -} diff --git a/packages/extension/src/shared/activity/utils/transform/explorerTransaction/transformers/dappJediswapSwapTransformer.ts b/packages/extension/src/shared/activity/utils/transform/explorerTransaction/transformers/dappJediswapSwapTransformer.ts deleted file mode 100644 index 6596c9188..000000000 --- a/packages/extension/src/shared/activity/utils/transform/explorerTransaction/transformers/dappJediswapSwapTransformer.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { SwapTransaction } from "../../type" -import { getEntityWithName } from "../getEntityWithName" -import { getParameter } from "../getParameter" -import { IExplorerTransactionTransformer } from "./type" - -/** Jediswap Swap */ - -export default function ({ - explorerTransaction, - result, - fingerprint, -}: IExplorerTransactionTransformer) { - if ( - result.dapp?.id === "jediswap-xyz" && - fingerprint === - "events[Approval,Transfer,Sync,Swap] calls[approve,swap_exact_tokens_for_tokens]" - ) { - const { calls, events } = explorerTransaction - const event = getEntityWithName(events, "Swap") - const call = getEntityWithName(calls, "swap_exact_tokens_for_tokens") - if (event && call) { - const path = getParameter(call.parameters, "path") - if (Array.isArray(path)) { - const action = "SWAP" - const entity = "TOKEN" - const dappContractAddress = call.address - const fromTokenAddress = path[0] - const toTokenAddress = path[path.length - 1] - const fromAmount = getParameter( - event.parameters ?? undefined, - "amount1In", - ) - const toAmount = getParameter( - event.parameters ?? undefined, - "amount0Out", - ) - result = { - ...result, - action, - entity, - dappContractAddress, - fromTokenAddress, - toTokenAddress, - fromAmount, - toAmount, - } as SwapTransaction - return result - } - } - } -} diff --git a/packages/extension/src/shared/activity/utils/transform/explorerTransaction/transformers/dappMintSquareBuyNFTTransformer.ts b/packages/extension/src/shared/activity/utils/transform/explorerTransaction/transformers/dappMintSquareBuyNFTTransformer.ts deleted file mode 100644 index 1533b3097..000000000 --- a/packages/extension/src/shared/activity/utils/transform/explorerTransaction/transformers/dappMintSquareBuyNFTTransformer.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { NFTTransaction } from "../../type" -import { getEntityWithName } from "../getEntityWithName" -import { getParameter } from "../getParameter" -import { IExplorerTransactionTransformer } from "./type" - -/** Mint Square buy NFT */ - -export default function ({ - explorerTransaction, - result, - fingerprint, -}: IExplorerTransactionTransformer) { - if ( - result.dapp?.id === "mintsquare-io" && - fingerprint === - "events[Transfer,Transfer,Approval,Transfer,TakerBid] calls[matchAskWithTakerBid]" - ) { - const { calls, events } = explorerTransaction - const call = getEntityWithName(calls, "matchAskWithTakerBid") - if (call) { - const action = "BUY" - const entity = "NFT" - const displayName = "Buy NFT" - const contractAddress = events[2].address - const tokenId = getParameter(events[2].parameters ?? undefined, "value") - result = { - ...result, - action, - entity, - displayName, - contractAddress, - tokenId, - } as NFTTransaction - return result - } - } -} diff --git a/packages/extension/src/shared/activity/utils/transform/explorerTransaction/transformers/dappMySwapSwapTransformer.ts b/packages/extension/src/shared/activity/utils/transform/explorerTransaction/transformers/dappMySwapSwapTransformer.ts deleted file mode 100644 index 07d1b7a97..000000000 --- a/packages/extension/src/shared/activity/utils/transform/explorerTransaction/transformers/dappMySwapSwapTransformer.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { SwapTransaction } from "../../type" -import { getEntityWithName } from "../getEntityWithName" -import { getParameter } from "../getParameter" -import { IExplorerTransactionTransformer } from "./type" - -/** mySwap swap */ - -export default function ({ - explorerTransaction, - result, - fingerprint, -}: IExplorerTransactionTransformer) { - if ( - result.dapp?.id === "myswap-xyz" && - fingerprint === "events[Approval,Transfer,Transfer] calls[approve]" - ) { - const { calls, events } = explorerTransaction - const call = getEntityWithName(calls, "approve") - if (call) { - const action = "SWAP" - const entity = "TOKEN" - const fromTokenAddress = events[1].address - const toTokenAddress = events[2].address - const fromAmount = getParameter( - events[1].parameters ?? undefined, - "value", - ) - const toAmount = getParameter(events[2].parameters ?? undefined, "value") - result = { - ...result, - action, - entity, - fromTokenAddress, - toTokenAddress, - fromAmount, - toAmount, - } as SwapTransaction - return result - } - } -} diff --git a/packages/extension/src/shared/activity/utils/transform/explorerTransaction/transformers/dateTransformer.ts b/packages/extension/src/shared/activity/utils/transform/explorerTransaction/transformers/dateTransformer.ts index e77316d75..4b5f74173 100644 --- a/packages/extension/src/shared/activity/utils/transform/explorerTransaction/transformers/dateTransformer.ts +++ b/packages/extension/src/shared/activity/utils/transform/explorerTransaction/transformers/dateTransformer.ts @@ -1,4 +1,4 @@ -import { IExplorerTransactionTransformer } from "./type" +import type { IExplorerTransactionTransformer } from "./type" /** date from timestamp */ diff --git a/packages/extension/src/shared/activity/utils/transform/explorerTransaction/transformers/defaultDisplayNameTransformer.ts b/packages/extension/src/shared/activity/utils/transform/explorerTransaction/transformers/defaultDisplayNameTransformer.ts index 67f1a75f5..c8c17a8c6 100644 --- a/packages/extension/src/shared/activity/utils/transform/explorerTransaction/transformers/defaultDisplayNameTransformer.ts +++ b/packages/extension/src/shared/activity/utils/transform/explorerTransaction/transformers/defaultDisplayNameTransformer.ts @@ -3,7 +3,7 @@ import { getCallNames, getPreExecutionEventNames, } from "../fingerprintExplorerTransaction" -import { IExplorerTransactionTransformer } from "./type" +import type { IExplorerTransactionTransformer } from "./type" /** default displayName from calls or events */ diff --git a/packages/extension/src/shared/activity/utils/transform/explorerTransaction/transformers/feesTransformer.ts b/packages/extension/src/shared/activity/utils/transform/explorerTransaction/transformers/feesTransformer.ts index ffac1a452..492a235ae 100644 --- a/packages/extension/src/shared/activity/utils/transform/explorerTransaction/transformers/feesTransformer.ts +++ b/packages/extension/src/shared/activity/utils/transform/explorerTransaction/transformers/feesTransformer.ts @@ -1,6 +1,6 @@ import { number } from "starknet" -import { IExplorerTransactionTransformer } from "./type" +import type { IExplorerTransactionTransformer } from "./type" import { getActualFee } from "../getActualFee" /** fees */ diff --git a/packages/extension/src/shared/activity/utils/transform/explorerTransaction/transformers/knownDappTransformer.ts b/packages/extension/src/shared/activity/utils/transform/explorerTransaction/transformers/knownDappTransformer.ts deleted file mode 100644 index f13a8133a..000000000 --- a/packages/extension/src/shared/activity/utils/transform/explorerTransaction/transformers/knownDappTransformer.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { getKnownDappForExplorerTransaction } from "../dappExplorerTransaction" -import { IExplorerTransactionTransformer } from "./type" - -/** adds known dapp and contract address */ - -export default function ({ - explorerTransaction, - result, -}: IExplorerTransactionTransformer) { - const knownDapp = getKnownDappForExplorerTransaction(explorerTransaction) - if (knownDapp) { - const { dapp, dappContractAddress } = knownDapp - /** omit the contracts */ - const { contracts: _contracts, ...rest } = dapp - result = { - ...result, - entity: "DAPP", - dapp: rest, - dappContractAddress, - } - } - return result -} diff --git a/packages/extension/src/shared/activity/utils/transform/explorerTransaction/transformers/knownNftTransformer.ts b/packages/extension/src/shared/activity/utils/transform/explorerTransaction/transformers/knownNftTransformer.ts index 6948201ad..290a953e4 100644 --- a/packages/extension/src/shared/activity/utils/transform/explorerTransaction/transformers/knownNftTransformer.ts +++ b/packages/extension/src/shared/activity/utils/transform/explorerTransaction/transformers/knownNftTransformer.ts @@ -1,7 +1,7 @@ import { isEqualAddress, includesAddress } from "@argent/x-shared" -import { NFTTransferTransaction } from "../../type" -import { IExplorerTransactionTransformer } from "./type" +import type { NFTTransferTransaction } from "../../type" +import type { IExplorerTransactionTransformer } from "./type" /** adds erc721 token transfer data */ diff --git a/packages/extension/src/shared/activity/utils/transform/explorerTransaction/transformers/postSwapTransformer.ts b/packages/extension/src/shared/activity/utils/transform/explorerTransaction/transformers/postSwapTransformer.ts index f8dd9237b..4ffa6e225 100644 --- a/packages/extension/src/shared/activity/utils/transform/explorerTransaction/transformers/postSwapTransformer.ts +++ b/packages/extension/src/shared/activity/utils/transform/explorerTransaction/transformers/postSwapTransformer.ts @@ -1,6 +1,6 @@ import { getTokenForContractAddress } from "../../getTokenForContractAddress" import { isSwapTransaction } from "../../is" -import { IExplorerTransactionTransformer } from "./type" +import type { IExplorerTransactionTransformer } from "./type" /** adds token swap tokens */ diff --git a/packages/extension/src/shared/activity/utils/transform/explorerTransaction/transformers/postTransferTransformer.ts b/packages/extension/src/shared/activity/utils/transform/explorerTransaction/transformers/postTransferTransformer.ts index 9c6d87757..58c8e81cd 100644 --- a/packages/extension/src/shared/activity/utils/transform/explorerTransaction/transformers/postTransferTransformer.ts +++ b/packages/extension/src/shared/activity/utils/transform/explorerTransaction/transformers/postTransferTransformer.ts @@ -1,4 +1,4 @@ -import { IExplorerTransactionTransformer } from "./type" +import type { IExplorerTransactionTransformer } from "./type" import { getTokenForContractAddress } from "../../getTokenForContractAddress" import { isTokenApproveTransaction, diff --git a/packages/extension/src/shared/activity/utils/transform/explorerTransaction/transformers/tokenApproveTransformer.ts b/packages/extension/src/shared/activity/utils/transform/explorerTransaction/transformers/tokenApproveTransformer.ts index f3878b506..4c6657fc7 100644 --- a/packages/extension/src/shared/activity/utils/transform/explorerTransaction/transformers/tokenApproveTransformer.ts +++ b/packages/extension/src/shared/activity/utils/transform/explorerTransaction/transformers/tokenApproveTransformer.ts @@ -1,6 +1,6 @@ -import { TokenApproveTransaction } from "../../type" +import type { TokenApproveTransaction } from "../../type" import { getParameter } from "../getParameter" -import { IExplorerTransactionTransformer } from "./type" +import type { IExplorerTransactionTransformer } from "./type" /** adds erc20 token approve data */ diff --git a/packages/extension/src/shared/activity/utils/transform/explorerTransaction/transformers/tokenMintTransformer.ts b/packages/extension/src/shared/activity/utils/transform/explorerTransaction/transformers/tokenMintTransformer.ts index 252fa7536..8950fd2dd 100644 --- a/packages/extension/src/shared/activity/utils/transform/explorerTransaction/transformers/tokenMintTransformer.ts +++ b/packages/extension/src/shared/activity/utils/transform/explorerTransaction/transformers/tokenMintTransformer.ts @@ -1,6 +1,6 @@ -import { TokenMintTransaction } from "../../type" +import type { TokenMintTransaction } from "../../type" import { getParameter } from "../getParameter" -import { IExplorerTransactionTransformer } from "./type" +import type { IExplorerTransactionTransformer } from "./type" /** adds erc20 token mint data */ diff --git a/packages/extension/src/shared/activity/utils/transform/explorerTransaction/transformers/tokenTransferTransformer.ts b/packages/extension/src/shared/activity/utils/transform/explorerTransaction/transformers/tokenTransferTransformer.ts index cb3f63eaa..15c5e8c37 100644 --- a/packages/extension/src/shared/activity/utils/transform/explorerTransaction/transformers/tokenTransferTransformer.ts +++ b/packages/extension/src/shared/activity/utils/transform/explorerTransaction/transformers/tokenTransferTransformer.ts @@ -1,7 +1,7 @@ import { isEqualAddress } from "@argent/x-shared" -import { TokenTransferTransaction } from "../../type" +import type { TokenTransferTransaction } from "../../type" import { getParameter } from "../getParameter" -import { IExplorerTransactionTransformer } from "./type" +import type { IExplorerTransactionTransformer } from "./type" /** adds erc20 token transfer data */ diff --git a/packages/extension/src/shared/activity/utils/transform/explorerTransaction/transformers/type.ts b/packages/extension/src/shared/activity/utils/transform/explorerTransaction/transformers/type.ts index 04675f015..894705b74 100644 --- a/packages/extension/src/shared/activity/utils/transform/explorerTransaction/transformers/type.ts +++ b/packages/extension/src/shared/activity/utils/transform/explorerTransaction/transformers/type.ts @@ -1,6 +1,6 @@ -import { IExplorerTransaction } from "../../../../../explorer/type" -import { Token } from "../../../../../token/__new/types/token.model" -import { TransformedTransaction } from "../../type" +import type { IExplorerTransaction } from "../../../../../explorer/type" +import type { Token } from "../../../../../token/__new/types/token.model" +import type { TransformedTransaction } from "../../type" export interface IExplorerTransactionTransformer { result: TransformedTransaction diff --git a/packages/extension/src/shared/activity/utils/transform/getTokenForContractAddress.ts b/packages/extension/src/shared/activity/utils/transform/getTokenForContractAddress.ts index 5fa9fb184..21b3e1a51 100644 --- a/packages/extension/src/shared/activity/utils/transform/getTokenForContractAddress.ts +++ b/packages/extension/src/shared/activity/utils/transform/getTokenForContractAddress.ts @@ -1,5 +1,5 @@ import { isEqualAddress } from "@argent/x-shared" -import { Token } from "../../../token/__new/types/token.model" +import type { Token } from "../../../token/__new/types/token.model" import { parsedDefaultTokens } from "../../../token/__new/utils" export const getTokenForContractAddress = ( diff --git a/packages/extension/src/shared/activity/utils/transform/getTransactionFailureReason.ts b/packages/extension/src/shared/activity/utils/transform/getTransactionFailureReason.ts index 0922b569e..77ee9ce9e 100644 --- a/packages/extension/src/shared/activity/utils/transform/getTransactionFailureReason.ts +++ b/packages/extension/src/shared/activity/utils/transform/getTransactionFailureReason.ts @@ -1,4 +1,4 @@ -import { ExtendedTransactionStatus } from "../../../transactions" +import type { ExtendedTransactionStatus } from "../../../transactions" export type ActivityTransactionFailureReason = | "REVERTED" diff --git a/packages/extension/src/shared/activity/utils/transform/transaction/getCallsFromTransaction.ts b/packages/extension/src/shared/activity/utils/transform/transaction/getCallsFromTransaction.ts index b4719f766..bc99e306c 100644 --- a/packages/extension/src/shared/activity/utils/transform/transaction/getCallsFromTransaction.ts +++ b/packages/extension/src/shared/activity/utils/transform/transaction/getCallsFromTransaction.ts @@ -1,5 +1,5 @@ -import { Transaction } from "../../../../transactions" -import { ActivityTransaction } from "../type" +import type { Transaction } from "../../../../transactions" +import type { ActivityTransaction } from "../type" /** * Return an array of calls from transaction diff --git a/packages/extension/src/shared/activity/utils/transform/transaction/getTransactionSubtitle.test.ts b/packages/extension/src/shared/activity/utils/transform/transaction/getTransactionSubtitle.test.ts index 21a2211ac..5591eaa33 100644 --- a/packages/extension/src/shared/activity/utils/transform/transaction/getTransactionSubtitle.test.ts +++ b/packages/extension/src/shared/activity/utils/transform/transaction/getTransactionSubtitle.test.ts @@ -9,9 +9,12 @@ import { } from "../../../../call/__fixtures__/transaction-calls/sepolia-alpha" import { makeTransaction, accountAddress } from "./transformTransaction.test" import { getTransactionSubtitle } from "./getTransactionSubtitle" +import { getRandomAccountIdentifier } from "../../../../utils/accountIdentifier" const networkId = "sepolia-alpha" +const accountId = getRandomAccountIdentifier("0x123", networkId) + describe("getTransactionSubtitle", () => { describe("when valid", () => { describe("and the address is known", () => { @@ -27,6 +30,7 @@ describe("getTransactionSubtitle", () => { (await getTransactionSubtitle({ transactionTransformed, networkId, + accountId, })), ).toMatchInlineSnapshot(`"To: 0x0541…b9fa"`) }) @@ -42,6 +46,7 @@ describe("getTransactionSubtitle", () => { (await getTransactionSubtitle({ transactionTransformed, networkId, + accountId, getAddressName: () => "Foo bar", })), ).toMatchInlineSnapshot(`"To: Foo bar"`) @@ -58,8 +63,9 @@ describe("getTransactionSubtitle", () => { (await getTransactionSubtitle({ transactionTransformed, networkId, + accountId, })), - ).toMatchInlineSnapshot(`"Alpha Road"`) + ).toMatchInlineSnapshot(`undefined`) }) }) }) diff --git a/packages/extension/src/shared/activity/utils/transform/transaction/getTransactionSubtitle.ts b/packages/extension/src/shared/activity/utils/transform/transaction/getTransactionSubtitle.ts index 181ba219d..f56ec0ac5 100644 --- a/packages/extension/src/shared/activity/utils/transform/transaction/getTransactionSubtitle.ts +++ b/packages/extension/src/shared/activity/utils/transform/transaction/getTransactionSubtitle.ts @@ -1,7 +1,7 @@ import { formatTruncatedAddress } from "@argent/x-shared" import type { AllowPromise } from "../../../../storage/types" -import type { BaseWalletAccount } from "../../../../wallet.model" +import type { AccountId, BaseWalletAccount } from "../../../../wallet.model" import { isDeclareContractTransaction, isDeployContractTransaction, @@ -17,15 +17,17 @@ type GetAddressName = ( export interface GetTransactionSubtitleProps { transactionTransformed: TransformedTransaction networkId: string + accountId: AccountId getAddressName?: GetAddressName } export async function getTransactionSubtitle({ transactionTransformed, networkId, + accountId, getAddressName, }: GetTransactionSubtitleProps) { - const { action, dapp } = transactionTransformed + const { action } = transactionTransformed const isNFTTransfer = isNFTTransferTransaction(transactionTransformed) const isTransfer = isTokenTransferTransaction(transactionTransformed) const isDeclareContract = isDeclareContractTransaction(transactionTransformed) @@ -41,15 +43,12 @@ export async function getTransactionSubtitle({ (await getAddressName?.({ address, networkId, + id: accountId, })) ?? formatTruncatedAddress(address) const subtitle = `${titleShowsTo ? "To:" : "From:"} ${accountName}` return subtitle } - if (dapp) { - return dapp.title - } - if (isDeclareContract) { return transactionTransformed.classHash } diff --git a/packages/extension/src/shared/activity/utils/transform/transaction/transformTransaction.test.ts b/packages/extension/src/shared/activity/utils/transform/transaction/transformTransaction.test.ts index f92757ba5..a1a3fcc02 100644 --- a/packages/extension/src/shared/activity/utils/transform/transaction/transformTransaction.test.ts +++ b/packages/extension/src/shared/activity/utils/transform/transaction/transformTransaction.test.ts @@ -1,7 +1,7 @@ -import { Call } from "starknet" +import type { Call } from "starknet" import { describe, expect, test } from "vitest" -import { Transaction } from "../../../../transactions" +import type { Transaction } from "../../../../transactions" import { nftContractAddresses } from "../__fixtures__/nftContractAddresses" import { tokensByNetwork } from "../__fixtures__/tokensByNetwork" import { transformTransaction } from "./transformTransaction" @@ -24,13 +24,22 @@ import { multisigReplaceOwner, rejectOnChain, } from "../../../../call/__fixtures__/transaction-calls/sepolia-alpha" +import { getMockSigner } from "../../../../../../test/account.mock" +import { getAccountIdentifier } from "../../../../utils/accountIdentifier" export const accountAddress = "0x7e00d496e324876bbc8531f2d9a82bf154d1a04a50218ee74cdd372f75a551a" +const id = getAccountIdentifier( + accountAddress, + "sepolia-alpha", + getMockSigner(), +) + export const makeTransaction = (transactions: Call | Call[]): Transaction => { return { account: { + id, name: "Account 1", address: accountAddress, type: "standard", @@ -99,7 +108,7 @@ describe("transformTransaction", () => { "decimals": 18, "iconUrl": "https://dv3jj1unlp2jl.cloudfront.net/128/color/eth.png", "id": 1, - "name": "Ether", + "name": "Ethereum", "network": "sepolia-alpha", "networkId": "sepolia-alpha", "showAlways": true, @@ -161,17 +170,9 @@ describe("transformTransaction", () => { ).toMatchInlineSnapshot(` { "action": "UNKNOWN", - "dapp": { - "hosts": [ - "testnet.app.alpharoad.fi", - ], - "id": "alpharoad-fi", - "title": "Alpha Road", - }, - "dappContractAddress": "0x4aec73f0611a9be0524e7ef21ab1679bdf9c97dc7d72614f15373d431226b6a", "date": "2022-09-01T15:47:40.000Z", "displayName": "Approve and swap exact tokens for tokens", - "entity": "DAPP", + "entity": "UNKNOWN", } `) expect( @@ -184,17 +185,9 @@ describe("transformTransaction", () => { ).toMatchInlineSnapshot(` { "action": "UNKNOWN", - "dapp": { - "hosts": [ - "app.testnet.jediswap.xyz", - ], - "id": "jediswap-xyz", - "title": "JediSwap", - }, - "dappContractAddress": "0x012b063b60553c91ed237d8905dff412fba830c5716b17821063176c6c073341", "date": "2022-09-01T15:47:40.000Z", "displayName": "Approve and swap exact tokens for tokens", - "entity": "DAPP", + "entity": "UNKNOWN", } `) expect( @@ -207,18 +200,9 @@ describe("transformTransaction", () => { ).toMatchInlineSnapshot(` { "action": "UNKNOWN", - "dapp": { - "hosts": [ - "www.myswap.xyz", - ], - "icon": "https://www.myswap.xyz/favicon.ico", - "id": "myswap-xyz", - "title": "mySwap", - }, - "dappContractAddress": "0x018a439bcbb1b3535a6145c1dc9bc6366267d923f60a84bd0c7618f33c81d334", "date": "2022-09-01T15:47:40.000Z", "displayName": "Approve and swap", - "entity": "DAPP", + "entity": "UNKNOWN", } `) expect( diff --git a/packages/extension/src/shared/activity/utils/transform/transaction/transformTransaction.ts b/packages/extension/src/shared/activity/utils/transform/transaction/transformTransaction.ts index ea98c6640..d3ad6334e 100644 --- a/packages/extension/src/shared/activity/utils/transform/transaction/transformTransaction.ts +++ b/packages/extension/src/shared/activity/utils/transform/transaction/transformTransaction.ts @@ -1,6 +1,6 @@ -import { Token } from "../../../../token/__new/types/token.model" -import { Transaction } from "../../../../transactions" -import { TransformedTransaction, ActivityTransaction } from "../type" +import type { Token } from "../../../../token/__new/types/token.model" +import type { Transaction } from "../../../../transactions" +import type { TransformedTransaction, ActivityTransaction } from "../type" import changeMultisigThresholdTransformer from "./transformers/changeMultisigThresholdTransformer" import addMultisigTransformer from "./transformers/changeMultisigTransformer" import dateTransformer from "./transformers/dateTransformer" @@ -8,7 +8,6 @@ import declareContractTransformer from "./transformers/declareContractTransforme import defaultDisplayNameTransformer from "./transformers/defaultDisplayNameTransformer" import deployContractTransformer from "./transformers/deployContractTransformer" import guardianTransformer from "./transformers/guardianTransformer" -import knownDappTransformer from "./transformers/knownDappTransformer" import nftTransferTransformer from "./transformers/nftTransferTransformer" import onChainRejectTransformer from "./transformers/onChainRejectTransformer" import postTransferTransformer from "./transformers/postTransferTransformer" @@ -17,11 +16,7 @@ import tokenTransferTransformer from "./transformers/tokenTransferTransformer" import upgradeAccountTransformer from "./transformers/upgradeAccountTransformer" /** all are executed */ -const preTransformers = [ - dateTransformer, - defaultDisplayNameTransformer, - knownDappTransformer, -] +const preTransformers = [dateTransformer, defaultDisplayNameTransformer] /** all are executed until one returns */ const mainTransformers = [ diff --git a/packages/extension/src/shared/activity/utils/transform/transaction/transformers/changeMultisigThresholdTransformer.ts b/packages/extension/src/shared/activity/utils/transform/transaction/transformers/changeMultisigThresholdTransformer.ts index ed4e99c7e..3e1533cf4 100644 --- a/packages/extension/src/shared/activity/utils/transform/transaction/transformers/changeMultisigThresholdTransformer.ts +++ b/packages/extension/src/shared/activity/utils/transform/transaction/transformers/changeMultisigThresholdTransformer.ts @@ -1,7 +1,7 @@ import { isChangeTresholdMultisigCall } from "../../../../../call/setMultisigThresholdCalls" -import { ChangeMultisigThresholdTransaction } from "../../type" +import type { ChangeMultisigThresholdTransaction } from "../../type" import { getCallsFromTransaction } from "../getCallsFromTransaction" -import { ITransactionTransformer } from "./type" +import type { ITransactionTransformer } from "./type" export default function ({ transaction, result }: ITransactionTransformer) { const calls = getCallsFromTransaction(transaction) diff --git a/packages/extension/src/shared/activity/utils/transform/transaction/transformers/changeMultisigTransformer.ts b/packages/extension/src/shared/activity/utils/transform/transaction/transformers/changeMultisigTransformer.ts index 7056e7b21..277ae40de 100644 --- a/packages/extension/src/shared/activity/utils/transform/transaction/transformers/changeMultisigTransformer.ts +++ b/packages/extension/src/shared/activity/utils/transform/transaction/transformers/changeMultisigTransformer.ts @@ -3,9 +3,9 @@ import { isRemoveMultisigSignersCall, isReplaceMultisigSignerCall, } from "../../../../../call/changeMultisigSignersCall" -import { ChangeMultisigSignerTransaction } from "../../type" +import type { ChangeMultisigSignerTransaction } from "../../type" import { getCallsFromTransaction } from "../getCallsFromTransaction" -import { ITransactionTransformer } from "./type" +import type { ITransactionTransformer } from "./type" export default function ({ transaction, result }: ITransactionTransformer) { const calls = getCallsFromTransaction(transaction) diff --git a/packages/extension/src/shared/activity/utils/transform/transaction/transformers/dateTransformer.ts b/packages/extension/src/shared/activity/utils/transform/transaction/transformers/dateTransformer.ts index acec21ca3..1fe8ec149 100644 --- a/packages/extension/src/shared/activity/utils/transform/transaction/transformers/dateTransformer.ts +++ b/packages/extension/src/shared/activity/utils/transform/transaction/transformers/dateTransformer.ts @@ -1,4 +1,4 @@ -import { ITransactionTransformer } from "./type" +import type { ITransactionTransformer } from "./type" /** date from timestamp */ diff --git a/packages/extension/src/shared/activity/utils/transform/transaction/transformers/declareContractTransformer.ts b/packages/extension/src/shared/activity/utils/transform/transaction/transformers/declareContractTransformer.ts index d29a1d9f2..a620aca8f 100644 --- a/packages/extension/src/shared/activity/utils/transform/transaction/transformers/declareContractTransformer.ts +++ b/packages/extension/src/shared/activity/utils/transform/transaction/transformers/declareContractTransformer.ts @@ -1,7 +1,7 @@ import { isUdcDeclareCall } from "../../../../../call/udcDeclareCall" -import { DeclareContractTransaction } from "../../type" +import type { DeclareContractTransaction } from "../../type" import { getCallsFromTransaction } from "../getCallsFromTransaction" -import { ITransactionTransformer } from "./type" +import type { ITransactionTransformer } from "./type" export default function ({ transaction, result }: ITransactionTransformer) { const calls = getCallsFromTransaction(transaction) diff --git a/packages/extension/src/shared/activity/utils/transform/transaction/transformers/defaultDisplayNameTransformer.ts b/packages/extension/src/shared/activity/utils/transform/transaction/transformers/defaultDisplayNameTransformer.ts index c0f8df491..30187ab4d 100644 --- a/packages/extension/src/shared/activity/utils/transform/transaction/transformers/defaultDisplayNameTransformer.ts +++ b/packages/extension/src/shared/activity/utils/transform/transaction/transformers/defaultDisplayNameTransformer.ts @@ -1,7 +1,7 @@ import { formatTruncatedAddress } from "@argent/x-shared" import { transactionNamesToTitle } from "../../../../../transactions" import { getCallsFromTransaction } from "../getCallsFromTransaction" -import { ITransactionTransformer } from "./type" +import type { ITransactionTransformer } from "./type" /** default displayName from calls */ diff --git a/packages/extension/src/shared/activity/utils/transform/transaction/transformers/deployContractTransformer.ts b/packages/extension/src/shared/activity/utils/transform/transaction/transformers/deployContractTransformer.ts index f31886ce8..e0071c4b4 100644 --- a/packages/extension/src/shared/activity/utils/transform/transaction/transformers/deployContractTransformer.ts +++ b/packages/extension/src/shared/activity/utils/transform/transaction/transformers/deployContractTransformer.ts @@ -1,7 +1,7 @@ import { isUdcDeployCall } from "../../../../../call/udcDeployCall" -import { DeployContractTransaction } from "../../type" +import type { DeployContractTransaction } from "../../type" import { getCallsFromTransaction } from "../getCallsFromTransaction" -import { ITransactionTransformer } from "./type" +import type { ITransactionTransformer } from "./type" export default function ({ transaction, result }: ITransactionTransformer) { const calls = getCallsFromTransaction(transaction) diff --git a/packages/extension/src/shared/activity/utils/transform/transaction/transformers/guardianTransformer.ts b/packages/extension/src/shared/activity/utils/transform/transaction/transformers/guardianTransformer.ts index b3b15673f..99148d7f2 100644 --- a/packages/extension/src/shared/activity/utils/transform/transaction/transformers/guardianTransformer.ts +++ b/packages/extension/src/shared/activity/utils/transform/transaction/transformers/guardianTransformer.ts @@ -1,9 +1,9 @@ import { CallData } from "starknet" import { isChangeGuardianCall } from "../../../../../call/changeGuardianCall" -import { ChangeGuardianTransaction } from "../../type" +import type { ChangeGuardianTransaction } from "../../type" import { getCallsFromTransaction } from "../getCallsFromTransaction" -import { ITransactionTransformer } from "./type" +import type { ITransactionTransformer } from "./type" import { ChangeGuardian, changeGuardianCalldataToType, diff --git a/packages/extension/src/shared/activity/utils/transform/transaction/transformers/knownDappTransformer.ts b/packages/extension/src/shared/activity/utils/transform/transaction/transformers/knownDappTransformer.ts deleted file mode 100644 index aea7d56ae..000000000 --- a/packages/extension/src/shared/activity/utils/transform/transaction/transformers/knownDappTransformer.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { getKnownDappForTransaction } from "../../explorerTransaction/dappTransaction" -import { ITransactionTransformer } from "./type" - -/** adds known dapp and contract address */ - -export default function ({ transaction, result }: ITransactionTransformer) { - const knownDapp = getKnownDappForTransaction(transaction) - if (knownDapp) { - const { dapp, dappContractAddress } = knownDapp - /** omit the contracts */ - const { contracts: _contracts, ...rest } = dapp - result = { - ...result, - entity: "DAPP", - dapp: rest, - dappContractAddress, - } - } - return result -} diff --git a/packages/extension/src/shared/activity/utils/transform/transaction/transformers/nftTransferTransformer.ts b/packages/extension/src/shared/activity/utils/transform/transaction/transformers/nftTransferTransformer.ts index 708dc7c4b..182e3dabe 100644 --- a/packages/extension/src/shared/activity/utils/transform/transaction/transformers/nftTransferTransformer.ts +++ b/packages/extension/src/shared/activity/utils/transform/transaction/transformers/nftTransferTransformer.ts @@ -2,9 +2,9 @@ import { isNftTransferCall, parseNftTransferCall, } from "../../../../../call/nftTransferCall" -import { NFTTransferTransaction } from "../../type" +import type { NFTTransferTransaction } from "../../type" import { getCallsFromTransaction } from "../getCallsFromTransaction" -import { ITransactionTransformer } from "./type" +import type { ITransactionTransformer } from "./type" /** adds erc721 token transfer data */ diff --git a/packages/extension/src/shared/activity/utils/transform/transaction/transformers/onChainRejectTransformer.test.ts b/packages/extension/src/shared/activity/utils/transform/transaction/transformers/onChainRejectTransformer.test.ts index bcc0c78cc..46507256b 100644 --- a/packages/extension/src/shared/activity/utils/transform/transaction/transformers/onChainRejectTransformer.test.ts +++ b/packages/extension/src/shared/activity/utils/transform/transaction/transformers/onChainRejectTransformer.test.ts @@ -1,10 +1,12 @@ -import { Transaction } from "../../../../../transactions" +import type { Transaction } from "../../../../../transactions" +import { getRandomAccountIdentifier } from "../../../../../utils/accountIdentifier" import { SignerType } from "../../../../../wallet.model" -import { TransformedTransaction } from "../../type" +import type { TransformedTransaction } from "../../type" import onChainRejectTransformer from "./onChainRejectTransformer" const mockTransaction: Transaction = { account: { + id: getRandomAccountIdentifier("0x123", "sepolia-alpha"), name: "Account", type: "multisig", network: { diff --git a/packages/extension/src/shared/activity/utils/transform/transaction/transformers/onChainRejectTransformer.ts b/packages/extension/src/shared/activity/utils/transform/transaction/transformers/onChainRejectTransformer.ts index 605309a44..1d78e92ea 100644 --- a/packages/extension/src/shared/activity/utils/transform/transaction/transformers/onChainRejectTransformer.ts +++ b/packages/extension/src/shared/activity/utils/transform/transaction/transformers/onChainRejectTransformer.ts @@ -1,7 +1,7 @@ import { isRejectOnChainCall } from "../../../../../call/rejectOnChainCall" -import { RejectOnChainTransaction } from "../../type" +import type { RejectOnChainTransaction } from "../../type" import { getCallsFromTransaction } from "../getCallsFromTransaction" -import { ITransactionTransformer } from "./type" +import type { ITransactionTransformer } from "./type" export default function ({ transaction, diff --git a/packages/extension/src/shared/activity/utils/transform/transaction/transformers/pendingMultisigTransactionAdapter.ts b/packages/extension/src/shared/activity/utils/transform/transaction/transformers/pendingMultisigTransactionAdapter.ts index c4cb82d13..1e88dd675 100644 --- a/packages/extension/src/shared/activity/utils/transform/transaction/transformers/pendingMultisigTransactionAdapter.ts +++ b/packages/extension/src/shared/activity/utils/transform/transaction/transformers/pendingMultisigTransactionAdapter.ts @@ -1,6 +1,6 @@ -import { MultisigPendingTransaction } from "../../../../../multisig/pendingTransactionsStore" -import { Transaction } from "../../../../../transactions" -import { WalletAccount } from "../../../../../wallet.model" +import type { MultisigPendingTransaction } from "../../../../../multisig/pendingTransactionsStore" +import type { Transaction } from "../../../../../transactions" +import type { WalletAccount } from "../../../../../wallet.model" export const getTransactionFromPendingMultisigTransaction = ( pendingMultisigTransaction: MultisigPendingTransaction, diff --git a/packages/extension/src/shared/activity/utils/transform/transaction/transformers/postTransferTransformer.ts b/packages/extension/src/shared/activity/utils/transform/transaction/transformers/postTransferTransformer.ts index f4b04417d..dd33a9e25 100644 --- a/packages/extension/src/shared/activity/utils/transform/transaction/transformers/postTransferTransformer.ts +++ b/packages/extension/src/shared/activity/utils/transform/transaction/transformers/postTransferTransformer.ts @@ -1,6 +1,6 @@ import { getTokenForContractAddress } from "../../getTokenForContractAddress" import { isTokenMintTransaction, isTokenTransferTransaction } from "../../is" -import { ITransactionTransformer } from "./type" +import type { ITransactionTransformer } from "./type" /** adds token transfer */ diff --git a/packages/extension/src/shared/activity/utils/transform/transaction/transformers/tokenMintTransformer.ts b/packages/extension/src/shared/activity/utils/transform/transaction/transformers/tokenMintTransformer.ts index 1263f0855..de836881c 100644 --- a/packages/extension/src/shared/activity/utils/transform/transaction/transformers/tokenMintTransformer.ts +++ b/packages/extension/src/shared/activity/utils/transform/transaction/transformers/tokenMintTransformer.ts @@ -1,8 +1,8 @@ import { isErc20MintCall } from "../../../../../call" import { parseErc20Call } from "../../../../../call/erc20Call" -import { TokenMintTransaction } from "../../type" +import type { TokenMintTransaction } from "../../type" import { getCallsFromTransaction } from "../getCallsFromTransaction" -import { ITransactionTransformer } from "./type" +import type { ITransactionTransformer } from "./type" /** adds erc20 token mint data */ diff --git a/packages/extension/src/shared/activity/utils/transform/transaction/transformers/tokenTransferTransformer.ts b/packages/extension/src/shared/activity/utils/transform/transaction/transformers/tokenTransferTransformer.ts index 7f8b32170..a81bb1556 100644 --- a/packages/extension/src/shared/activity/utils/transform/transaction/transformers/tokenTransferTransformer.ts +++ b/packages/extension/src/shared/activity/utils/transform/transaction/transformers/tokenTransferTransformer.ts @@ -1,8 +1,8 @@ import { isErc20TransferCall } from "../../../../../call" import { parseErc20Call } from "../../../../../call/erc20Call" -import { TokenTransferTransaction } from "../../type" +import type { TokenTransferTransaction } from "../../type" import { getCallsFromTransaction } from "../getCallsFromTransaction" -import { ITransactionTransformer } from "./type" +import type { ITransactionTransformer } from "./type" /** adds erc20 token transfer data */ diff --git a/packages/extension/src/shared/activity/utils/transform/transaction/transformers/type.ts b/packages/extension/src/shared/activity/utils/transform/transaction/transformers/type.ts index 367bce141..e2ee39319 100644 --- a/packages/extension/src/shared/activity/utils/transform/transaction/transformers/type.ts +++ b/packages/extension/src/shared/activity/utils/transform/transaction/transformers/type.ts @@ -1,6 +1,6 @@ -import { Token } from "../../../../../token/__new/types/token.model" -import { Transaction } from "../../../../../transactions" -import { ActivityTransaction, TransformedTransaction } from "../../type" +import type { Token } from "../../../../../token/__new/types/token.model" +import type { Transaction } from "../../../../../transactions" +import type { ActivityTransaction, TransformedTransaction } from "../../type" export interface ITransactionTransformer { result: TransformedTransaction diff --git a/packages/extension/src/shared/activity/utils/transform/transaction/transformers/upgradeAccountTransformer.test.ts b/packages/extension/src/shared/activity/utils/transform/transaction/transformers/upgradeAccountTransformer.test.ts index 1ef6db5a9..2fa295851 100644 --- a/packages/extension/src/shared/activity/utils/transform/transaction/transformers/upgradeAccountTransformer.test.ts +++ b/packages/extension/src/shared/activity/utils/transform/transaction/transformers/upgradeAccountTransformer.test.ts @@ -1,14 +1,25 @@ import { describe, it, expect } from "vitest" -import { Transaction } from "../../../../../transactions" +import type { Transaction } from "../../../../../transactions" import { SignerType } from "../../../../../wallet.model" -import { TransformedTransaction } from "../../type" +import type { TransformedTransaction } from "../../type" import upgradeAccountTransformer from "./upgradeAccountTransformer" -import { Call } from "starknet" +import type { Call } from "starknet" +import { getAccountIdentifier } from "../../../../../utils/accountIdentifier" + +export const accountAddress = + "0x07059f14f63fe428f802520078965ead76fbe8693d1f7bd88de74a887cccf418" + +const mockSigner = { + type: SignerType.LOCAL_SECRET, + derivationPath: "m/44'/60'/0'/0/0", +} + +const id = getAccountIdentifier(accountAddress, "sepolia-alpha", mockSigner) const mockTransaction: Transaction = { account: { - address: - "0x07059f14f63fe428f802520078965ead76fbe8693d1f7bd88de74a887cccf418", + id, + address: accountAddress, cairoVersion: "1", classHash: "0x0737ee2f87ce571a58c6c8da558ec18a07ceb64a6172d5ec46171fbc80077a48", diff --git a/packages/extension/src/shared/activity/utils/transform/transaction/transformers/upgradeAccountTransformer.ts b/packages/extension/src/shared/activity/utils/transform/transaction/transformers/upgradeAccountTransformer.ts index 5650d318a..6e9052892 100644 --- a/packages/extension/src/shared/activity/utils/transform/transaction/transformers/upgradeAccountTransformer.ts +++ b/packages/extension/src/shared/activity/utils/transform/transaction/transformers/upgradeAccountTransformer.ts @@ -2,9 +2,9 @@ import { extractNewClassHash, isUpgradeAccountCall, } from "../../../../../call/upgradeAccountCall" -import { UpgradeAccountTransaction } from "../../type" +import type { UpgradeAccountTransaction } from "../../type" import { getCallsFromTransaction } from "../getCallsFromTransaction" -import { ITransactionTransformer } from "./type" +import type { ITransactionTransformer } from "./type" export default function ({ transaction, result }: ITransactionTransformer) { const calls = getCallsFromTransaction(transaction) diff --git a/packages/extension/src/shared/activity/utils/transform/type.ts b/packages/extension/src/shared/activity/utils/transform/type.ts index 8f18cf35e..a80cd4ea8 100644 --- a/packages/extension/src/shared/activity/utils/transform/type.ts +++ b/packages/extension/src/shared/activity/utils/transform/type.ts @@ -1,8 +1,7 @@ -import { Address } from "@argent/x-shared" -import { KnownDapp } from "../../../knownDapps" -import { Token } from "../../../token/__new/types/token.model" -import { TransactionMeta } from "../../../transactions" -import { ActivityTransactionFailureReason } from "./getTransactionFailureReason" +import type { Address } from "@argent/x-shared" +import type { Token } from "../../../token/__new/types/token.model" +import type { TransactionMeta } from "../../../transactions" +import type { ActivityTransactionFailureReason } from "./getTransactionFailureReason" export type TransformedTransactionAction = | "UNKNOWN" @@ -42,7 +41,6 @@ export interface BaseTransformedTransaction { maxFee?: string actualFee?: string dappContractAddress?: string - dapp?: Omit } export interface TokenTransferTransaction extends BaseTransformedTransaction { @@ -89,7 +87,6 @@ export interface SwapTransaction extends BaseTransformedTransaction { action: "SWAP" contractAddress: string dappContractAddress?: string - dapp: Omit fromTokenAddress: string toTokenAddress: string fromAmount: string @@ -121,6 +118,10 @@ export interface ChangeMultisigSignerTransaction entity: "SIGNER" } +export interface ChangeMultisigSelfSignerTransaction + extends ChangeMultisigSignerTransaction { + newSigner: Address +} export interface ChangeMultisigThresholdTransaction extends BaseTransformedTransaction { action: "CHANGE" diff --git a/packages/extension/src/shared/addressBook/service/AddressBookService.ts b/packages/extension/src/shared/addressBook/service/AddressBookService.ts index d10417b59..a9acdabf0 100644 --- a/packages/extension/src/shared/addressBook/service/AddressBookService.ts +++ b/packages/extension/src/shared/addressBook/service/AddressBookService.ts @@ -5,7 +5,8 @@ import { addressBookContactNoIdSchema, addressBookContactSchema, } from "../schema" -import { IAddressBookRepo, compareAddressBookContacts } from "../store" +import type { IAddressBookRepo } from "../store" +import { compareAddressBookContacts } from "../store" import type { AddressBookContact, AddressBookContactNoId } from "../type" import type { IAddressBookService } from "./IAddressBookService" import { AddressBookError } from "../../errors/addressBook" diff --git a/packages/extension/src/shared/addressBook/type.ts b/packages/extension/src/shared/addressBook/type.ts index ac36bca99..36c93c2b4 100644 --- a/packages/extension/src/shared/addressBook/type.ts +++ b/packages/extension/src/shared/addressBook/type.ts @@ -1,6 +1,6 @@ -import { z } from "zod" +import type { z } from "zod" -import { +import type { addressBookContactNoIdSchema, addressBookContactSchema, } from "./schema" diff --git a/packages/extension/src/shared/analytics/AnalyticsService.ts b/packages/extension/src/shared/analytics/AnalyticsService.ts index be03ad6dc..77338b08b 100644 --- a/packages/extension/src/shared/analytics/AnalyticsService.ts +++ b/packages/extension/src/shared/analytics/AnalyticsService.ts @@ -1,8 +1,9 @@ -import { Ampli, Event, EventOptions, PromiseResult, Result } from "../../ampli" -import { WalletAccountSharedService } from "../account/service/accountSharedService/WalletAccountSharedService" +import type { Event, EventOptions, PromiseResult, Result } from "../../ampli" +import { Ampli } from "../../ampli" +import type { WalletAccountSharedService } from "../account/service/accountSharedService/WalletAccountSharedService" import { isProd } from "../api/constants" -import { ISettingsStorage } from "../settings/types" -import { KeyValueStorage } from "../storage" +import type { ISettingsStorage } from "../settings/types" +import type { KeyValueStorage } from "../storage" const getVoidPromiseResult = () => ({ promise: Promise.resolve() }) diff --git a/packages/extension/src/shared/analytics/init.ts b/packages/extension/src/shared/analytics/init.ts index c35cfe813..79ee1e474 100644 --- a/packages/extension/src/shared/analytics/init.ts +++ b/packages/extension/src/shared/analytics/init.ts @@ -1,6 +1,6 @@ import { UUID } from "@amplitude/analytics-core" import { ampli } from "." -import { Environment, LoadOptions } from "../../ampli" +import type { Environment, LoadOptions } from "../../ampli" import { settingsStore } from "../settings" import { StoreDexie } from "../smartAccount/idb" diff --git a/packages/extension/src/shared/api/constants.ts b/packages/extension/src/shared/api/constants.ts index 3d31ee430..55d1e4a2c 100644 --- a/packages/extension/src/shared/api/constants.ts +++ b/packages/extension/src/shared/api/constants.ts @@ -17,6 +17,10 @@ export const ARGENT_API_TOKENS_INFO_URL = ARGENT_API_ENABLED ? urlJoin(ARGENT_API_BASE_URL, "tokens/info?chain=starknet") : undefined +export const ARGENT_API_TOKENS_REPORT_SPAM_URL = ARGENT_API_ENABLED + ? urlJoin(ARGENT_API_BASE_URL, "tokens/scamTokenReports") + : undefined + export const ARGENT_TRANSACTION_REVIEW_API_BASE_URL = ARGENT_API_ENABLED ? urlJoin(ARGENT_API_BASE_URL, "reviewer") : undefined @@ -46,14 +50,6 @@ export const ARGENT_EXPLORER_BASE_URL = ARGENT_API_ENABLED export const ARGENT_EXPLORER_ENABLED = isValidString(ARGENT_EXPLORER_BASE_URL) -export const ARGENT_TRANSACTION_BULK_SIMULATION_URL = ARGENT_API_ENABLED - ? urlJoin(ARGENT_API_BASE_URL, "starknet/bulkSimulate") - : undefined - -export const ARGENT_TRANSACTION_SIMULATION_API_ENABLED = isValidString( - ARGENT_TRANSACTION_BULK_SIMULATION_URL, -) - export const ARGENT_MULTISIG_BASE_URL = ARGENT_API_ENABLED ? urlJoin(ARGENT_API_BASE_URL, "multisig/starknet/") : undefined @@ -103,6 +99,12 @@ export const ARGENT_NETWORK_STATUS = ARGENT_API_ENABLED : undefined export const isCI = Boolean(process.env.CI) + +// Playwright sets via `browserContext.addInitScript("window.PLAYWRIGHT = true;")` +export const isPlaywright = Boolean( + typeof window !== "undefined" && window.PLAYWRIGHT, +) + export const ARGENT_PORTFOLIO_MAINNET_BASE_URL = "https://portfolio.argent.xyz/overview/" export const ARGENT_PORTFOLIO_GOERLI_BASE_URL = @@ -123,6 +125,11 @@ export const ARGENT_ACCOUNTS_URL = ARGENT_API_ENABLED ? urlJoin(ARGENT_API_BASE_URL, "accounts") : undefined +// name resolution +export const ARGENT_NAME_RESOLUTION_API_BASE_URL = ARGENT_API_ENABLED + ? urlJoin(ARGENT_API_BASE_URL, "name-resolution", "resolve") + : undefined + export const TOPPER_WIDGET_ID = isProd ? "e03fb9ad-a21a-48f6-bbdf-47a23e5b8e74" : "975934b4-47ce-4329-bded-011c6ec3b8f3" @@ -132,3 +139,15 @@ export const TOPPER_KEY_ID = isProd export const TOPPER_BASE_URL = isProd ? "https://app.topperpay.com/" : "https://app.sandbox.topperpay.com/" + +export const ARGENT_TOKENS_GRAPH_API_URL = ARGENT_API_ENABLED + ? urlJoin(ARGENT_API_BASE_URL, "/tokens/graph") + : undefined + +export const ARGENT_TOKENS_INFO_URL = ARGENT_API_ENABLED + ? urlJoin(ARGENT_API_BASE_URL, "/tokens/info") + : undefined + +export const ARGENT_TOKENS_DEFI_INVESTMENTS_URL = ARGENT_API_ENABLED + ? urlJoin(ARGENT_API_BASE_URL, "/tokens/defi") + : undefined diff --git a/packages/extension/src/shared/api/fetcher.ts b/packages/extension/src/shared/api/fetcher.ts index 31443706e..5cdfbe15c 100644 --- a/packages/extension/src/shared/api/fetcher.ts +++ b/packages/extension/src/shared/api/fetcher.ts @@ -60,7 +60,7 @@ export const fetcher = async ( try { const json = JSON.parse(responseText) return json - } catch (parseError) { + } catch { throw fetcherError( "An error occurred while parsing", response, diff --git a/packages/extension/src/shared/argentAccount/IArgentAccountService.ts b/packages/extension/src/shared/argentAccount/IArgentAccountService.ts index e2e724c2c..a3b72496c 100644 --- a/packages/extension/src/shared/argentAccount/IArgentAccountService.ts +++ b/packages/extension/src/shared/argentAccount/IArgentAccountService.ts @@ -1,4 +1,4 @@ -import { +import type { AddSmartAccountRequest, AddSmartAccountResponse, } from "@argent/x-shared" diff --git a/packages/extension/src/shared/browser.ts b/packages/extension/src/shared/browser.ts index cd248110e..89d8a592d 100644 --- a/packages/extension/src/shared/browser.ts +++ b/packages/extension/src/shared/browser.ts @@ -1,4 +1,4 @@ -import { DeepPick } from "../shared/types/deepPick" +import type { DeepPick } from "../shared/types/deepPick" export type MinimalActionBrowser = DeepPick< typeof chrome, diff --git a/packages/extension/src/shared/call/changeGuardianCall.ts b/packages/extension/src/shared/call/changeGuardianCall.ts index 07c5d369b..495c92639 100644 --- a/packages/extension/src/shared/call/changeGuardianCall.ts +++ b/packages/extension/src/shared/call/changeGuardianCall.ts @@ -1,4 +1,5 @@ -import { Call, validateAndParseAddress } from "starknet" +import type { Call } from "starknet" +import { validateAndParseAddress } from "starknet" export interface ChangeGuardianCall extends Call { entrypoint: "changeGuardian" | "change_guardian" @@ -15,7 +16,7 @@ export const isChangeGuardianCall = ( validateAndParseAddress(call.contractAddress) return true } - } catch (e) { + } catch { // failure implies invalid } return false diff --git a/packages/extension/src/shared/call/changeMultisigSignersCall.test.ts b/packages/extension/src/shared/call/changeMultisigSignersCall.test.ts new file mode 100644 index 000000000..b5893e47e --- /dev/null +++ b/packages/extension/src/shared/call/changeMultisigSignersCall.test.ts @@ -0,0 +1,78 @@ +import type { Call } from "starknet" +import { MultisigEntryPointType } from "../multisig/types" +import { ETH_TOKEN_ADDRESS } from "../network/constants" +import type { ReplaceMultisigSignerCall } from "./changeMultisigSignersCall" +import { + getNewSignerInReplaceMultisigSignerCall, + isReplaceMultisigSignerCall, + isReplaceSelfAsSignerInMultisigCall, +} from "./changeMultisigSignersCall" + +const SELF_PUB_KEY = + "0x02b12f420d9a638a516bee76cbd6786d3eca08d9e589e0f293d85472b9036204" +const RANDOM_PUB_KEY = + "0x07de7b34da32652e524fffe0ed5713b1642524aeea6cb1c2305ff699bf04329f" + +describe("changeMultisigSignersCall", () => { + test("isReplaceMultisigSignerCall should return true for valid call", () => { + const call: Call = { + contractAddress: ETH_TOKEN_ADDRESS, + entrypoint: MultisigEntryPointType.REPLACE_SIGNER, + calldata: [SELF_PUB_KEY, RANDOM_PUB_KEY], + } + + expect(isReplaceMultisigSignerCall(call)).toBe(true) + }) + + test("isReplaceSelfAsSignerInMultisigCall should return true for valid call", () => { + const call: Call = { + contractAddress: ETH_TOKEN_ADDRESS, + entrypoint: MultisigEntryPointType.REPLACE_SIGNER, + calldata: [SELF_PUB_KEY, RANDOM_PUB_KEY], + } + + expect(isReplaceSelfAsSignerInMultisigCall(call, SELF_PUB_KEY)).toBe(true) + }) + + test("isReplaceSelfAsSignerInMultisigCall should return false for invalid calldata length", () => { + const selfPubKey = "0x123" + const call: Call = { + contractAddress: ETH_TOKEN_ADDRESS, + entrypoint: MultisigEntryPointType.REPLACE_SIGNER, + calldata: [SELF_PUB_KEY, RANDOM_PUB_KEY, "0x345"], + } + expect(isReplaceSelfAsSignerInMultisigCall(call, selfPubKey)).toBe(false) + }) + + test("isReplaceSelfAsSignerInMultisigCall should return false for invalid selfPubKey", () => { + const call: Call = { + contractAddress: ETH_TOKEN_ADDRESS, + entrypoint: MultisigEntryPointType.REPLACE_SIGNER, + calldata: [SELF_PUB_KEY, RANDOM_PUB_KEY, "0x345"], + } + + expect(isReplaceSelfAsSignerInMultisigCall(call, SELF_PUB_KEY)).toBe(false) + }) + + test("getNewSignerInReplaceMultisigSignerCall should return the correct signer", () => { + const call: ReplaceMultisigSignerCall = { + contractAddress: ETH_TOKEN_ADDRESS, + entrypoint: MultisigEntryPointType.REPLACE_SIGNER, + calldata: [SELF_PUB_KEY, RANDOM_PUB_KEY], + } + + expect(getNewSignerInReplaceMultisigSignerCall(call)).toEqual( + RANDOM_PUB_KEY, + ) + }) + + test("getNewSignerInReplaceMultisigSignerCall should return undefined for incorrect call", () => { + const call: ReplaceMultisigSignerCall = { + contractAddress: ETH_TOKEN_ADDRESS, + entrypoint: MultisigEntryPointType.REPLACE_SIGNER, + calldata: [SELF_PUB_KEY, RANDOM_PUB_KEY, "0x345"], + } + + expect(getNewSignerInReplaceMultisigSignerCall(call)).toBeUndefined() + }) +}) diff --git a/packages/extension/src/shared/call/changeMultisigSignersCall.ts b/packages/extension/src/shared/call/changeMultisigSignersCall.ts index 85f321c92..bad714ef5 100644 --- a/packages/extension/src/shared/call/changeMultisigSignersCall.ts +++ b/packages/extension/src/shared/call/changeMultisigSignersCall.ts @@ -1,4 +1,7 @@ -import { Call, validateAndParseAddress } from "starknet" +import type { Address } from "@argent/x-shared" +import { addressSchema, isEqualAddress } from "@argent/x-shared" +import type { Call } from "starknet" +import { CallData, num, validateAndParseAddress } from "starknet" import { MultisigEntryPointType } from "../multisig/types" export interface AddMultisigSignersCall extends Call { @@ -16,7 +19,7 @@ export const isAddMultisigSignersCall = ( validateAndParseAddress(call.contractAddress) return true } - } catch (e) { + } catch { // failure implies invalid } return false @@ -37,7 +40,7 @@ export const isRemoveMultisigSignersCall = ( validateAndParseAddress(call.contractAddress) return true } - } catch (e) { + } catch { // failure implies invalid } return false @@ -49,7 +52,7 @@ export interface ReplaceMultisigSignerCall extends Call { export const isReplaceMultisigSignerCall = ( call: Call, -): call is RemoveMultisigSignersCall => { +): call is ReplaceMultisigSignerCall => { try { if ( call.contractAddress && @@ -58,8 +61,41 @@ export const isReplaceMultisigSignerCall = ( validateAndParseAddress(call.contractAddress) return true } - } catch (e) { + } catch { // failure implies invalid } return false } + +export const isReplaceSelfAsSignerInMultisigCall = ( + call: Call, + selfPubKey: string, +): call is ReplaceMultisigSignerCall => { + try { + if ( + call.contractAddress && + call.entrypoint === MultisigEntryPointType.REPLACE_SIGNER + ) { + const calldata = CallData.toCalldata(call.calldata) + const replacedPubKey = addressSchema.parse(num.toHex(calldata[0])) + return calldata.length === 2 && isEqualAddress(replacedPubKey, selfPubKey) + } + } catch { + // failure implies invalid + } + return false +} + +export const getNewSignerInReplaceMultisigSignerCall = ( + call: ReplaceMultisigSignerCall, +): Address | undefined => { + if (!isReplaceMultisigSignerCall(call)) { + return + } + const calldata = CallData.toCalldata(call.calldata) + if (calldata.length !== 2) { + return + } + const newPubKey = addressSchema.parse(num.toHex(calldata[1])) + return newPubKey +} diff --git a/packages/extension/src/shared/call/erc20ApproveCall.ts b/packages/extension/src/shared/call/erc20ApproveCall.ts index 354dd29ce..1926d58ae 100644 --- a/packages/extension/src/shared/call/erc20ApproveCall.ts +++ b/packages/extension/src/shared/call/erc20ApproveCall.ts @@ -1,6 +1,7 @@ -import { Call } from "starknet" +import type { Call } from "starknet" -import { Erc20Call, parseErc20Call, validateERC20Call } from "./erc20Call" +import type { Erc20Call } from "./erc20Call" +import { parseErc20Call, validateERC20Call } from "./erc20Call" export interface Erc20ApproveCall extends Erc20Call { entrypoint: "approve" @@ -22,7 +23,7 @@ export const isErc20ApproveCall = (call: Call): call is Erc20ApproveCall => { ) { return validateERC20Call(call as Erc20ApproveCall) } - } catch (e) { + } catch { // failure implies invalid } return false diff --git a/packages/extension/src/shared/call/erc20Call.ts b/packages/extension/src/shared/call/erc20Call.ts index 86a69ed50..22e421823 100644 --- a/packages/extension/src/shared/call/erc20Call.ts +++ b/packages/extension/src/shared/call/erc20Call.ts @@ -1,11 +1,6 @@ import { normalizeAddress } from "@argent/x-shared" -import { - Call, - constants, - num, - uint256, - validateAndParseAddress, -} from "starknet" +import type { Call } from "starknet" +import { constants, num, uint256, validateAndParseAddress } from "starknet" const { isUint256, uint256ToBN } = uint256 @@ -40,7 +35,7 @@ export const validateERC20Call = (call: Erc20Call) => { if (isUint256(amount) && amount > constants.ZERO) { return true } - } catch (e) { + } catch { // failure implies invalid } return false diff --git a/packages/extension/src/shared/call/erc20MintCall.ts b/packages/extension/src/shared/call/erc20MintCall.ts index e3496cab8..f27311608 100644 --- a/packages/extension/src/shared/call/erc20MintCall.ts +++ b/packages/extension/src/shared/call/erc20MintCall.ts @@ -1,6 +1,7 @@ -import { Call } from "starknet" +import type { Call } from "starknet" -import { Erc20Call, parseErc20Call, validateERC20Call } from "./erc20Call" +import type { Erc20Call } from "./erc20Call" +import { parseErc20Call, validateERC20Call } from "./erc20Call" export interface Erc20MintCall extends Erc20Call { entrypoint: "mint" @@ -15,7 +16,7 @@ export const isErc20MintCall = (call: Call): call is Erc20MintCall => { ) { return validateERC20Call(call as Erc20MintCall) } - } catch (e) { + } catch { // failure implies invalid } return false diff --git a/packages/extension/src/shared/call/erc20TransferCall.test.ts b/packages/extension/src/shared/call/erc20TransferCall.test.ts index ebc1582ad..abdd1606b 100644 --- a/packages/extension/src/shared/call/erc20TransferCall.test.ts +++ b/packages/extension/src/shared/call/erc20TransferCall.test.ts @@ -1,8 +1,8 @@ -import { Call } from "starknet" +import type { Call } from "starknet" import { describe, expect, test } from "vitest" +import type { Erc20TransferCall } from "./erc20TransferCall" import { - Erc20TransferCall, isErc20TransferCall, parseErc20TransferCall, } from "./erc20TransferCall" diff --git a/packages/extension/src/shared/call/erc20TransferCall.ts b/packages/extension/src/shared/call/erc20TransferCall.ts index 46d704e00..b9fe5925d 100644 --- a/packages/extension/src/shared/call/erc20TransferCall.ts +++ b/packages/extension/src/shared/call/erc20TransferCall.ts @@ -1,6 +1,7 @@ -import { Call } from "starknet" +import type { Call } from "starknet" -import { Erc20Call, parseErc20Call, validateERC20Call } from "./erc20Call" +import type { Erc20Call } from "./erc20Call" +import { parseErc20Call, validateERC20Call } from "./erc20Call" export interface Erc20TransferCall extends Erc20Call { entrypoint: "transfer" @@ -21,7 +22,7 @@ export const isErc20TransferCall = (call: Call): call is Erc20TransferCall => { ) { return validateERC20Call(call as Erc20TransferCall) } - } catch (e) { + } catch { // failure implies invalid } return false diff --git a/packages/extension/src/shared/call/nftTransferCall.test.ts b/packages/extension/src/shared/call/nftTransferCall.test.ts index b04fcd33a..4f42d4f8b 100644 --- a/packages/extension/src/shared/call/nftTransferCall.test.ts +++ b/packages/extension/src/shared/call/nftTransferCall.test.ts @@ -4,7 +4,8 @@ import { erc1155Transfer, erc721Transfer, } from "./__fixtures__/transaction-calls/sepolia-alpha" -import { NftTransferCall, parseNftTransferCall } from "./nftTransferCall" +import type { NftTransferCall } from "./nftTransferCall" +import { parseNftTransferCall } from "./nftTransferCall" describe("nftTransferCall", () => { describe("parseNftTransferCall()", () => { diff --git a/packages/extension/src/shared/call/nftTransferCall.ts b/packages/extension/src/shared/call/nftTransferCall.ts index c1974b77c..42960e54b 100644 --- a/packages/extension/src/shared/call/nftTransferCall.ts +++ b/packages/extension/src/shared/call/nftTransferCall.ts @@ -1,5 +1,6 @@ import { normalizeAddress } from "@argent/x-shared" -import { Call, CallData, num, uint256, validateAndParseAddress } from "starknet" +import type { Call } from "starknet" +import { CallData, num, uint256, validateAndParseAddress } from "starknet" const { uint256ToBN } = uint256 @@ -40,7 +41,7 @@ export const isNftTransferCall = (call: Call): call is NftTransferCall => { }) return tokenId !== undefined } - } catch (e) { + } catch { // failure implies invalid } return false diff --git a/packages/extension/src/shared/call/rejectOnChainCall.test.ts b/packages/extension/src/shared/call/rejectOnChainCall.test.ts index f2ae863fe..4b8e35f2b 100644 --- a/packages/extension/src/shared/call/rejectOnChainCall.test.ts +++ b/packages/extension/src/shared/call/rejectOnChainCall.test.ts @@ -1,4 +1,4 @@ -import { Call } from "starknet" +import type { Call } from "starknet" import { ETH_TOKEN_ADDRESS, STRK_TOKEN_ADDRESS } from "../network/constants" import { isRejectOnChainCall } from "./rejectOnChainCall" diff --git a/packages/extension/src/shared/call/rejectOnChainCall.ts b/packages/extension/src/shared/call/rejectOnChainCall.ts index 3585d014f..c4495dbb5 100644 --- a/packages/extension/src/shared/call/rejectOnChainCall.ts +++ b/packages/extension/src/shared/call/rejectOnChainCall.ts @@ -1,14 +1,9 @@ import { isEqualAddress } from "@argent/x-shared" -import { - Call, - constants, - uint256, - validateAndParseAddress, - num, -} from "starknet" +import type { Call } from "starknet" +import { constants, uint256, validateAndParseAddress, num } from "starknet" import { ETH_TOKEN_ADDRESS } from "../network/constants" -import { Erc20Call } from "./erc20Call" -import { Erc20TransferCall } from "./erc20TransferCall" +import type { Erc20Call } from "./erc20Call" +import type { Erc20TransferCall } from "./erc20TransferCall" const { isUint256, uint256ToBN } = uint256 /** @@ -27,7 +22,7 @@ export const isRejectOnChainCall = ( ) { return validateRejectOnChainCall(call as Erc20TransferCall, senderAddress) } - } catch (e) { + } catch { // failure implies invalid } return false @@ -63,7 +58,7 @@ export const validateRejectOnChainCall = ( if (isUint256(amount) && amount === constants.ZERO) { return true } - } catch (e) { + } catch { // failure implies invalid } return false diff --git a/packages/extension/src/shared/call/setMultisigThresholdCalls.ts b/packages/extension/src/shared/call/setMultisigThresholdCalls.ts index e6dfc7fc6..04e0c4fef 100644 --- a/packages/extension/src/shared/call/setMultisigThresholdCalls.ts +++ b/packages/extension/src/shared/call/setMultisigThresholdCalls.ts @@ -1,4 +1,5 @@ -import { Call, validateAndParseAddress } from "starknet" +import type { Call } from "starknet" +import { validateAndParseAddress } from "starknet" import { MultisigEntryPointType } from "../multisig/types" export interface ChangeTresholdMultisigCall extends Call { @@ -16,7 +17,7 @@ export const isChangeTresholdMultisigCall = ( validateAndParseAddress(call.contractAddress) return true } - } catch (e) { + } catch { // failure implies invalid } return false diff --git a/packages/extension/src/shared/call/udcDeclareCall.ts b/packages/extension/src/shared/call/udcDeclareCall.ts index 4385da0fd..a5e00d386 100644 --- a/packages/extension/src/shared/call/udcDeclareCall.ts +++ b/packages/extension/src/shared/call/udcDeclareCall.ts @@ -1,5 +1,6 @@ import { isEqualAddress } from "@argent/x-shared" -import { Call, constants, validateAndParseAddress } from "starknet" +import type { Call } from "starknet" +import { constants, validateAndParseAddress } from "starknet" const { UDC } = constants @@ -17,7 +18,7 @@ export const isUdcDeclareCall = (call: Call): call is UdcDeclareCall => { validateAndParseAddress(call.contractAddress) return true } - } catch (e) { + } catch { // failure implies invalid } return false diff --git a/packages/extension/src/shared/call/udcDeployCall.ts b/packages/extension/src/shared/call/udcDeployCall.ts index 2266c5fe6..7cf31f9ee 100644 --- a/packages/extension/src/shared/call/udcDeployCall.ts +++ b/packages/extension/src/shared/call/udcDeployCall.ts @@ -1,5 +1,6 @@ import { isEqualAddress } from "@argent/x-shared" -import { Call, constants, validateAndParseAddress } from "starknet" +import type { Call } from "starknet" +import { constants, validateAndParseAddress } from "starknet" const { UDC } = constants @@ -17,7 +18,7 @@ export const isUdcDeployCall = (call: Call): call is UdcDeployCall => { validateAndParseAddress(call.contractAddress) return true } - } catch (e) { + } catch { // failure implies invalid } return false diff --git a/packages/extension/src/shared/call/upgradeAccountCall.ts b/packages/extension/src/shared/call/upgradeAccountCall.ts index eb1e6798a..8765119a6 100644 --- a/packages/extension/src/shared/call/upgradeAccountCall.ts +++ b/packages/extension/src/shared/call/upgradeAccountCall.ts @@ -1,5 +1,7 @@ -import { Address, addressSchema } from "@argent/x-shared" -import { Call, validateAndParseAddress } from "starknet" +import type { Address } from "@argent/x-shared" +import { addressSchema } from "@argent/x-shared" +import type { Call } from "starknet" +import { validateAndParseAddress } from "starknet" import { MultisigEntryPointType } from "../multisig/types" export interface UpgradeAccountCall extends Call { @@ -17,7 +19,7 @@ export const isUpgradeAccountCall = ( validateAndParseAddress(call.contractAddress) return true } - } catch (e) { + } catch { // failure implies invalid } return false diff --git a/packages/extension/src/shared/chain/service/IChainService.ts b/packages/extension/src/shared/chain/service/IChainService.ts index bff938261..b0d2b3692 100644 --- a/packages/extension/src/shared/chain/service/IChainService.ts +++ b/packages/extension/src/shared/chain/service/IChainService.ts @@ -1,4 +1,4 @@ -import { +import type { BaseTransaction, TransactionWithStatus, } from "../../transactions/interface" diff --git a/packages/extension/src/shared/chain/service/StarknetChainService.test.ts b/packages/extension/src/shared/chain/service/StarknetChainService.test.ts index a85b2b84e..05966f946 100644 --- a/packages/extension/src/shared/chain/service/StarknetChainService.test.ts +++ b/packages/extension/src/shared/chain/service/StarknetChainService.test.ts @@ -1,9 +1,9 @@ import { describe, expect, test, vi } from "vitest" -import { INetworkService } from "../../network/service/INetworkService" -import { Network } from "../../network" +import type { INetworkService } from "../../network/service/INetworkService" +import type { Network } from "../../network" import { StarknetChainService } from "./StarknetChainService" -import { Hex } from "@argent/x-shared" +import type { Hex } from "@argent/x-shared" const mocks = vi.hoisted(() => { return { diff --git a/packages/extension/src/shared/chain/service/StarknetChainService.ts b/packages/extension/src/shared/chain/service/StarknetChainService.ts index 6effcaac8..00ca9b1b7 100644 --- a/packages/extension/src/shared/chain/service/StarknetChainService.ts +++ b/packages/extension/src/shared/chain/service/StarknetChainService.ts @@ -1,11 +1,11 @@ import { getProvider } from "../../network" -import { INetworkService } from "../../network/service/INetworkService" -import { +import type { INetworkService } from "../../network/service/INetworkService" +import type { BaseTransaction, TransactionStatus, TransactionWithStatus, } from "../../transactions/interface" -import { BaseContract, IChainService } from "./IChainService" +import type { BaseContract, IChainService } from "./IChainService" import { isContractDeployed } from "@argent/x-shared" import { SUCCESS_STATUSES } from "../../transactions" diff --git a/packages/extension/src/shared/chain/service/__test__/mock.ts b/packages/extension/src/shared/chain/service/__test__/mock.ts index 8bc90d8cf..f9adbf63e 100644 --- a/packages/extension/src/shared/chain/service/__test__/mock.ts +++ b/packages/extension/src/shared/chain/service/__test__/mock.ts @@ -1,8 +1,8 @@ -import { +import type { BaseTransaction, TransactionWithStatus, } from "../../../transactions/interface" -import { IChainService } from "../IChainService" +import type { IChainService } from "../IChainService" export class MockChainService implements IChainService { constructor( diff --git a/packages/extension/src/shared/config.ts b/packages/extension/src/shared/config.ts index 8c546d235..5fd782680 100644 --- a/packages/extension/src/shared/config.ts +++ b/packages/extension/src/shared/config.ts @@ -17,4 +17,6 @@ export const RefreshIntervalInSeconds = Object.freeze({ }) const isDev = process.env.NODE_ENV === "development" -isDev && console.log("Refresh intervals in seconds", RefreshIntervalInSeconds) +if (isDev) { + console.log("Refresh intervals in seconds", RefreshIntervalInSeconds) +} diff --git a/packages/extension/src/shared/debounce/DebounceService.ts b/packages/extension/src/shared/debounce/DebounceService.ts index 20688e5aa..1fc337778 100644 --- a/packages/extension/src/shared/debounce/DebounceService.ts +++ b/packages/extension/src/shared/debounce/DebounceService.ts @@ -1,6 +1,6 @@ -import { BaseScheduledTask } from "../schedule/IScheduleService" -import { IKeyValueStorage } from "../storage" -import { +import type { BaseScheduledTask } from "../schedule/IScheduleService" +import type { IKeyValueStorage } from "../storage" +import type { DebouncedImplementedScheduledTask, IDebounceService, } from "./IDebounceService" diff --git a/packages/extension/src/shared/debounce/IDebounceService.ts b/packages/extension/src/shared/debounce/IDebounceService.ts index 52f40866f..8ab30b43b 100644 --- a/packages/extension/src/shared/debounce/IDebounceService.ts +++ b/packages/extension/src/shared/debounce/IDebounceService.ts @@ -1,4 +1,4 @@ -import { +import type { ImplementedScheduledTask, BaseScheduledTask, } from "../schedule/IScheduleService" diff --git a/packages/extension/src/shared/debounce/index.ts b/packages/extension/src/shared/debounce/index.ts index 8ddd32d58..6cef219c8 100644 --- a/packages/extension/src/shared/debounce/index.ts +++ b/packages/extension/src/shared/debounce/index.ts @@ -11,4 +11,4 @@ const debounceStorage = new KeyValueStorage( export const debounceService = new DebounceService(debounceStorage) -export { IDebounceService } from "./IDebounceService" +export type { IDebounceService } from "./IDebounceService" diff --git a/packages/extension/src/shared/debounce/mock.ts b/packages/extension/src/shared/debounce/mock.ts index 6c967aa53..616269220 100644 --- a/packages/extension/src/shared/debounce/mock.ts +++ b/packages/extension/src/shared/debounce/mock.ts @@ -1,4 +1,4 @@ -import { IDebounceService } from "." +import type { IDebounceService } from "." export const getMockDebounceService = (): IDebounceService => { const isRunning: Record = {} diff --git a/packages/extension/src/shared/defiDecomposition/__fixtures__/collateralizedDebtPositions.ts b/packages/extension/src/shared/defiDecomposition/__fixtures__/collateralizedDebtPositions.ts new file mode 100644 index 000000000..b2ef3190c --- /dev/null +++ b/packages/extension/src/shared/defiDecomposition/__fixtures__/collateralizedDebtPositions.ts @@ -0,0 +1,148 @@ +import type { + ApiDefiDecompositionProduct, + DefiPositionType, +} from "@argent/x-shared" + +export const collateralizedDebtPositions: ApiDefiDecompositionProduct = { + productId: "e287cf81-8b05-425c-875b-ef031498ad31", + name: "Lending & Borrowing", + manageUrl: "https://vesu-git-sepolia-vesu.vercel.app/positions/earn", + groups: { + "1": { + healthRatio: "2.710918", + }, + "2": { + healthRatio: "4.930858", + }, + "3": { + healthRatio: "1.083022", + }, + }, + positions: [ + { + id: "39fedd17fc26b1fd2e52398e57b95e08", + investmentId: "f071cc6a-27d7-49c8-a87c-1b41ea4113a6", + tokenAddress: + "0x07632c8fab1399aede8ad1f89411f082b0a492ca58e87b5ebc475d38f799b0c7", + totalBalances: { + "0x027ef4670397069d7d5442cb7945b27338692de0d8896bdb15e6400cf5249f94": + "8000000099", + }, + data: { + apy: "0.000267", + totalApy: "0.0567", + collateral: false, + lending: true, + debt: false, + }, + }, + { + id: "73899a8006b1350ca8f5d093101cb6a3", + investmentId: "43e1aa72-06f2-4db6-9fb2-675b3cb3fc82", + tokenAddress: + "0x05868ed6b7c57ac071bf6bfe762174a2522858b700ba9fb062709e63b65bf186", + totalBalances: { + "0x063d32a3fa6074e72e7a1e06fe78c46a0c8473217773e19f11d8c8cbfc4ff8ca": + "802342857167", + }, + data: { + apy: "0.000001", + totalApy: "0.0001", + collateral: false, + lending: true, + debt: false, + }, + }, + { + id: "342570c9e9bf2a788f75db3743f51e9c", + totalBalances: { + "0x07809bb63f557736e49ff0ae4a64bd8aa6ea60e3f77f26c520cb92c24e3700d3": + "8000000000008124586153", + }, + data: { + apy: "0.000000", + totalApy: "0.0000", + group: 1, + collateral: true, + lending: true, + debt: false, + }, + }, + { + id: "d2f2e19ba29ce34b7461f0a5257c8fb1", + totalBalances: { + "0x063d32a3fa6074e72e7a1e06fe78c46a0c8473217773e19f11d8c8cbfc4ff8ca": + "-9371429032", + }, + data: { + apy: "0.001001", + totalApy: "0.01", + group: 1, + collateral: false, + lending: false, + debt: true, + }, + }, + { + id: "040e37d3d98432c500c743b20c250eb6", + totalBalances: { + "0x07809bb63f557736e49ff0ae4a64bd8aa6ea60e3f77f26c520cb92c24e3700d3": + "6000319500004940226185", + }, + data: { + apy: "0.000000", + totalApy: "0.0000", + group: 2, + collateral: true, + lending: true, + debt: false, + }, + }, + { + id: "8473bed540880f07b7f52c330103c870", + totalBalances: { + "0x057181b39020af1416747a7d0d2de6ad5a5b721183136585e8774e1425efd5d2": + "-900047986094880151114", + }, + data: { + apy: "0.001735", + totalApy: "0.01735", + group: 2, + collateral: false, + lending: false, + debt: true, + }, + }, + { + id: "328e835168d876676f4360c32f9f9753", + totalBalances: { + "0x0772131070c7d56f78f3e46b27b70271d8ca81c7c52e3f62aa868fab4b679e43": + "11999999999999999999999", + }, + data: { + apy: "0.000000", + totalApy: "0.0000", + group: 3, + collateral: true, + lending: true, + debt: false, + }, + }, + { + id: "7134500b8452fe18a841717346b7afdb", + totalBalances: { + "0x07809bb63f557736e49ff0ae4a64bd8aa6ea60e3f77f26c520cb92c24e3700d3": + "-1278000056797272710", + }, + data: { + apy: "0.001000", + totalApy: "0.01", + group: 3, + collateral: false, + lending: false, + debt: true, + }, + }, + ], + type: "collateralizedDebtPosition" as DefiPositionType, +} diff --git a/packages/extension/src/shared/defiDecomposition/__fixtures__/concentratedLiquidityPositions.ts b/packages/extension/src/shared/defiDecomposition/__fixtures__/concentratedLiquidityPositions.ts new file mode 100644 index 000000000..3ffc685ff --- /dev/null +++ b/packages/extension/src/shared/defiDecomposition/__fixtures__/concentratedLiquidityPositions.ts @@ -0,0 +1,78 @@ +import type { ApiDefiDecompositionProduct } from "@argent/x-shared" + +export const concentratedLiquidityPositions: ApiDefiDecompositionProduct = { + productId: "1", + name: "Concentrated liquidity", + manageUrl: "https://app.ekubo.org/positions", + positions: [ + { + id: "050195914b6bb85dce82f3222c12154c", + tokenAddress: + "0x07b696af58c967c1b14c9dde0ace001720635a660a8e90c565ea459345318b30", + tokenId: "367273", + totalBalances: { + "0x57181b39020af1416747a7d0d2de6ad5a5b721183136585e8774e1425efd5d2": + "1128442273967865732", + "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7": + "11107133261362", + }, + data: { + poolFeePercentage: "0.05", + tickSpacingPercentage: "0.1", + token0: { + tokenAddress: + "0x57181b39020af1416747a7d0d2de6ad5a5b721183136585e8774e1425efd5d2", + principal: "1102394379714798486", + accruedFees: "26047894253067246", + minPrice: "0.000424331267348977", + maxPrice: "0.000438129454574905", + currentPrice: "0.000383264801039327", + }, + token1: { + tokenAddress: + "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + principal: "0", + accruedFees: "11107133261362", + minPrice: "2282.430431367024993961", + maxPrice: "2356.649337314997410431", + currentPrice: "2609.16211790967372508", + }, + }, + }, + { + id: "a7b7ebede0d38d62542727df7c1c2cfb", + tokenAddress: + "0x07b696af58c967c1b14c9dde0ace001720635a660a8e90c565ea459345318b30", + tokenId: "343207", + totalBalances: { + "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7": + "158995656321138", + "0x57181b39020af1416747a7d0d2de6ad5a5b721183136585e8774e1425efd5d2": + "5469603", + }, + data: { + poolFeePercentage: "0.05", + tickSpacingPercentage: "0.1", + token0: { + tokenAddress: + "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + principal: "0", + accruedFees: "158995656321138", + minPrice: "2487.493917", + maxPrice: "2568.380972", + currentPrice: "2604.665835", + }, + token1: { + tokenAddress: + "0x57181b39020af1416747a7d0d2de6ad5a5b721183136585e8774e1425efd5d2", + principal: "5066944", + accruedFees: "402659", + minPrice: "0.000389350338108608", + maxPrice: "0.000402011033360373", + currentPrice: "0.000383926408709454", + }, + }, + }, + ], + type: "concentratedLiquidityPosition", +} diff --git a/packages/extension/src/shared/defiDecomposition/__fixtures__/defiDecomposition.ts b/packages/extension/src/shared/defiDecomposition/__fixtures__/defiDecomposition.ts new file mode 100644 index 000000000..8af0d172e --- /dev/null +++ b/packages/extension/src/shared/defiDecomposition/__fixtures__/defiDecomposition.ts @@ -0,0 +1,255 @@ +import type { ApiDefiPositions } from "@argent/x-shared" + +export const defiDecomposition: ApiDefiPositions = { + dapps: [ + { + dappId: "b513a7c1-eb8a-4201-876c-becd8d445e15", + products: [ + { + productId: "01a7b83d-d4c4-4109-af0d-ad5b15ab712b", + name: "Governance", + manageUrl: "https://app.ekubo.org/governance", + positions: [ + { + id: "99e96b150b3339b9a0c55f30a2b4f4e0", + totalBalances: { + "0x01fad7c03b2ea7fbef306764e20977f8d4eae6191b3a54e4514cc5fc9d19e569": + "528263624904970685", + }, + data: { + delegatingTo: + "0x04d8d7295721a1b972f5b9723f47ac73b1567c8d1e889cdc208840fb07816a54", + }, + }, + { + id: "b245685bf91c50dd6ff11279f77d8faa", + totalBalances: { + "0x01fad7c03b2ea7fbef306764e20977f8d4eae6191b3a54e4514cc5fc9d19e569": + "29364244105174014", + }, + data: { + delegatingTo: + "0x064d28d1d1d53a0b5de12e3678699bc9ba32c1cb19ce1c048578581ebb7f8396", + }, + }, + ], + type: "delegatedTokens", + }, + ], + }, + { + dappId: "02d43d9d-b82e-44fb-aaa1-69753adc2f14", + products: [ + { + productId: "e287cf81-8b05-425c-875b-ef031498ad31", + name: "Lending & Borrowing", + manageUrl: "https://vesu-git-sepolia-vesu.vercel.app/positions/earn", + groups: { + "1": { + healthRatio: "2.713544", + }, + "2": { + healthRatio: "4.873483", + }, + "3": { + healthRatio: "1.020603", + }, + }, + positions: [ + { + id: "39fedd17fc26b1fd2e52398e57b95e08", + investmentId: "f071cc6a-27d7-49c8-a87c-1b41ea4113a6", + tokenAddress: + "0x07632c8fab1399aede8ad1f89411f082b0a492ca58e87b5ebc475d38f799b0c7", + totalBalances: { + "0x027ef4670397069d7d5442cb7945b27338692de0d8896bdb15e6400cf5249f94": + "8000005167", + }, + data: { + apy: "0.000262", + totalApy: "0.0567", + lending: true, + debt: false, + collateral: false, + }, + }, + { + id: "73899a8006b1350ca8f5d093101cb6a3", + investmentId: "43e1aa72-06f2-4db6-9fb2-675b3cb3fc82", + tokenAddress: + "0x05868ed6b7c57ac071bf6bfe762174a2522858b700ba9fb062709e63b65bf186", + totalBalances: { + "0x063d32a3fa6074e72e7a1e06fe78c46a0c8473217773e19f11d8c8cbfc4ff8ca": + "802342859388", + }, + data: { + apy: "0.000001", + totalApy: "0.0001", + lending: true, + debt: false, + collateral: false, + }, + }, + { + id: "342570c9e9bf2a788f75db3743f51e9c", + totalBalances: { + "0x07809bb63f557736e49ff0ae4a64bd8aa6ea60e3f77f26c520cb92c24e3700d3": + "8000000000465072445874", + }, + data: { + apy: "0.000000", + totalApy: "0.0000", + group: 1, + lending: true, + debt: false, + collateral: true, + }, + }, + { + id: "d2f2e19ba29ce34b7461f0a5257c8fb1", + totalBalances: { + "0x063d32a3fa6074e72e7a1e06fe78c46a0c8473217773e19f11d8c8cbfc4ff8ca": + "-9371457586", + }, + data: { + apy: "0.001001", + totalApy: "0.001001", + group: 1, + lending: false, + debt: true, + collateral: false, + }, + }, + { + id: "040e37d3d98432c500c743b20c250eb6", + totalBalances: { + "0x07809bb63f557736e49ff0ae4a64bd8aa6ea60e3f77f26c520cb92c24e3700d3": + "6000319500347669370331", + }, + data: { + apy: "0.000000", + totalApy: "0.0000", + group: 2, + lending: true, + debt: false, + collateral: true, + }, + }, + { + id: "8473bed540880f07b7f52c330103c870", + totalBalances: { + "0x057181b39020af1416747a7d0d2de6ad5a5b721183136585e8774e1425efd5d2": + "-900052389162082643600", + }, + data: { + apy: "0.001018", + totalApy: "0.01018", + group: 2, + lending: false, + debt: true, + collateral: false, + }, + }, + { + id: "328e835168d876676f4360c32f9f9753", + totalBalances: { + "0x0772131070c7d56f78f3e46b27b70271d8ca81c7c52e3f62aa868fab4b679e43": + "11999999999999999999999", + }, + data: { + apy: "0.000000", + totalApy: "0.0000", + group: 3, + lending: true, + debt: false, + collateral: true, + }, + }, + { + id: "7134500b8452fe18a841717346b7afdb", + totalBalances: { + "0x07809bb63f557736e49ff0ae4a64bd8aa6ea60e3f77f26c520cb92c24e3700d3": + "-1278003947284119364", + }, + data: { + apy: "0.001000", + totalApy: "0.01000", + group: 3, + lending: false, + debt: true, + collateral: false, + }, + }, + ], + type: "collateralizedDebtPosition", + }, + ], + }, + { + dappId: "49007844-7a78-4185-be2f-b06bf6fc26a5", + products: [ + { + productId: "01a7b83d-d4c4-4109-af0d-ad5b15ab712b", + name: "Staking", + manageUrl: + "https://app.avnu.fi/?tokenFrom=0x49d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7&tokenTo=0x0703911a6196ef674fc635de02763dcd4ccc16d7cf736f68a9483cc44eccaa94", + positions: [ + { + id: "937a82be8c5c92d615fd851c82dc6f9f", + investmentId: "f77d6ce4-a799-475d-920d-c76734e2f748", + tokenAddress: + "0x0703911a6196ef674fc635de02763dcd4ccc16d7cf736f68a9483cc44eccaa94", + totalBalances: { + "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7": + "17623790831667140", + }, + data: { + apy: "0.030927", + totalApy: "0.050927", + }, + }, + ], + type: "staking", + }, + ], + }, + ], +} + +export const stakedStrkOnlyDefiDecomposition: ApiDefiPositions = { + dapps: [ + { + dappId: "ed0f9636-9a73-49e0-9f80-0932eafd0cee", + products: [ + { + productId: "5d4c7689-6759-4051-a6a2-baf4a397b081", + name: "Starknet Delegated Staking", + manageUrl: "todo", + positions: [ + { + id: "c0c050e8fb19fe3434cb9b3a9f0a7571", + investmentId: "ee3dae35-85ac-4e67-b3ee-4e436c102cba", + totalBalances: { + "0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d": + "20000000000000000000", + }, + data: { + stakerInfo: { + name: "Argent", + iconUrl: + "https://static.argent.net/dapp/logos/3131edcd-b7f4-47d4-89ae-bd461786e547.png", + address: + "0x041dc48224a5f025d07a3d73d7ce73bb03c730c36b02646e6291d3e95ba4a7a4", + }, + accruedRewards: "0", + apy: "0.01621", + totalApy: "0.01621", + }, + }, + ], + type: "strkDelegatedStaking", + }, + ], + }, + ], +} diff --git a/packages/extension/src/shared/defiDecomposition/__fixtures__/delegatedTokensPositions.ts b/packages/extension/src/shared/defiDecomposition/__fixtures__/delegatedTokensPositions.ts new file mode 100644 index 000000000..e2b01e754 --- /dev/null +++ b/packages/extension/src/shared/defiDecomposition/__fixtures__/delegatedTokensPositions.ts @@ -0,0 +1,32 @@ +import type { ApiDefiDecompositionProduct } from "@argent/x-shared" + +export const delegatedTokensPositions: ApiDefiDecompositionProduct = { + productId: "1", + name: "Governance", + manageUrl: "https://app.ekubo.org/governance", + positions: [ + { + id: "99e96b150b3339b9a0c55f30a2b4f4e0", + totalBalances: { + "0x01fad7c03b2ea7fbef306764e20977f8d4eae6191b3a54e4514cc5fc9d19e569": + "528263624904970685", + }, + data: { + delegatingTo: + "0x04d8d7295721a1b972f5b9723f47ac73b1567c8d1e889cdc208840fb07816a54", + }, + }, + { + id: "b245685bf91c50dd6ff11279f77d8faa", + totalBalances: { + "0x01fad7c03b2ea7fbef306764e20977f8d4eae6191b3a54e4514cc5fc9d19e569": + "29364244105174014", + }, + data: { + delegatingTo: + "0x064d28d1d1d53a0b5de12e3678699bc9ba32c1cb19ce1c048578581ebb7f8396", + }, + }, + ], + type: "delegatedTokens", +} diff --git a/packages/extension/src/shared/defiDecomposition/__fixtures__/parsedDefiDecompositionWithUsdValue.ts b/packages/extension/src/shared/defiDecomposition/__fixtures__/parsedDefiDecompositionWithUsdValue.ts new file mode 100644 index 000000000..c9a14be28 --- /dev/null +++ b/packages/extension/src/shared/defiDecomposition/__fixtures__/parsedDefiDecompositionWithUsdValue.ts @@ -0,0 +1,516 @@ +import type { ParsedDefiDecompositionWithUsdValue } from "../schema" + +export const parsedPositionsWithUsdValue: ParsedDefiDecompositionWithUsdValue = + [ + { + dappId: "b513a7c1-eb8a-4201-876c-becd8d445e15", + products: [ + { + type: "delegatedTokens", + manageUrl: "https://app.ekubo.org/governance", + name: "Delegated Governance", + positions: [ + { + id: "99e96b150b3339b9a0c55f30a2b4f4e0", + delegatingTo: + "0x04d8d7295721a1b972f5b9723f47ac73b1567c8d1e889cdc208840fb07816a54", + token: { + address: + "0x01fad7c03b2ea7fbef306764e20977f8d4eae6191b3a54e4514cc5fc9d19e569", + balance: "528263624904970685", + networkId: "sepolia-alpha", + usdValue: "1.050005476669488196418579885", + }, + }, + { + id: "b245685bf91c50dd6ff11279f77d8faa", + delegatingTo: + "0x064d28d1d1d53a0b5de12e3678699bc9ba32c1cb19ce1c048578581ebb7f8396", + token: { + address: + "0x01fad7c03b2ea7fbef306764e20977f8d4eae6191b3a54e4514cc5fc9d19e569", + balance: "29364244105174014", + networkId: "sepolia-alpha", + usdValue: "0.058365966678547907384014494", + }, + }, + ], + totalUsdValue: "1.108371443348036103802594379", + }, + ], + totalUsdValue: "1.108371443348036103802594379", + }, + { + dappId: "02d43d9d-b82e-44fb-aaa1-69753adc2f14", + products: [ + { + type: "collateralizedDebtLendingPosition", + manageUrl: "https://vesu-git-sepolia-vesu.vercel.app/positions/earn", + name: "Lending", + positions: [ + { + id: "39fedd17fc26b1fd2e52398e57b95e08", + lending: true, + apy: "0.000262", + group: "undefined", + collateral: false, + debt: false, + token: { + address: + "0x027ef4670397069d7d5442cb7945b27338692de0d8896bdb15e6400cf5249f94", + balance: "8000005167", + networkId: "sepolia-alpha", + usdValue: "7998.868427433808806882", + }, + }, + { + id: "73899a8006b1350ca8f5d093101cb6a3", + lending: true, + apy: "0.000001", + group: "undefined", + collateral: false, + debt: false, + token: { + address: + "0x063d32a3fa6074e72e7a1e06fe78c46a0c8473217773e19f11d8c8cbfc4ff8ca", + balance: "802342859388", + networkId: "sepolia-alpha", + usdValue: "1252645.40737625120985132", + }, + }, + ], + totalUsdValue: "1260644.275803685018658202", + }, + { + type: "collateralizedDebtBorrowingPosition", + manageUrl: "https://vesu-git-sepolia-vesu.vercel.app/positions/earn", + name: "Borrowing", + positions: [ + { + id: "1", + group: "1", + healthRatio: "2.713544", + collateralizedPositions: [ + { + id: "342570c9e9bf2a788f75db3743f51e9c", + lending: true, + apy: "0.000000", + group: "1", + collateral: true, + debt: false, + token: { + address: + "0x07809bb63f557736e49ff0ae4a64bd8aa6ea60e3f77f26c520cb92c24e3700d3", + balance: "8000000000465072445874", + networkId: "sepolia-alpha", + usdValue: "20870000.0012132577431737975", + }, + }, + ], + debtPositions: [ + { + id: "d2f2e19ba29ce34b7461f0a5257c8fb1", + lending: false, + apy: "0.001001", + group: "1", + collateral: false, + debt: true, + token: { + address: + "0x063d32a3fa6074e72e7a1e06fe78c46a0c8473217773e19f11d8c8cbfc4ff8ca", + balance: "-9371457586", + networkId: "sepolia-alpha", + usdValue: "-14631.04353477817251354", + }, + }, + ], + totalUsdValue: "20855368.9576784795706602575", + collateralizedPositionsTotalUsdValue: + "20870000.0012132577431737975", + debtPositionsTotalUsdValue: "14631.04353477817251354", + }, + { + id: "2", + group: "2", + healthRatio: "4.873483", + collateralizedPositions: [ + { + id: "040e37d3d98432c500c743b20c250eb6", + lending: true, + apy: "0.000000", + group: "2", + collateral: true, + debt: false, + token: { + address: + "0x07809bb63f557736e49ff0ae4a64bd8aa6ea60e3f77f26c520cb92c24e3700d3", + balance: "6000319500347669370331", + networkId: "sepolia-alpha", + usdValue: "15653333.49653198246985099625", + }, + }, + { + id: "050e37d3d98432c500c743b20c250eb6", + lending: true, + apy: "0.000000", + group: "2", + collateral: true, + debt: false, + token: { + address: + "0x27ef4670397069d7d5442cb7945b27338692de0d8896bdb15e6400cf5249f94", + balance: "1000319500347669370331", + networkId: "sepolia-alpha", + usdValue: "1000177362595112.866171755970450826", + }, + }, + { + id: "060e37d3d98432c500c743b20c250eb6", + lending: true, + apy: "0.000000", + group: "2", + collateral: true, + debt: false, + token: { + address: + "0x0124aeb495b947201f5fac96fd1138e326ad86195b98df6dec9009158a533b49", + balance: "1000319500347669370331", + networkId: "sepolia-alpha", + usdValue: "0", + }, + }, + ], + debtPositions: [ + { + id: "8473bed540880f07b7f52c330103c870", + lending: false, + apy: "0.001018", + group: "2", + collateral: false, + debt: true, + token: { + address: + "0x057181b39020af1416747a7d0d2de6ad5a5b721183136585e8774e1425efd5d2", + balance: "-900052389162082643600", + networkId: "sepolia-alpha", + usdValue: "-2638042.4408761844502403106429956", + }, + }, + ], + totalUsdValue: "1000177375610403.9218275539900615116070044", + collateralizedPositionsTotalUsdValue: + "20870000.0012132577431737975", + debtPositionsTotalUsdValue: "2638042.4408761844502403106429956", + }, + { + id: "3", + group: "3", + healthRatio: "1.020603", + collateralizedPositions: [ + { + id: "328e835168d876676f4360c32f9f9753", + lending: true, + apy: "0.000000", + group: "3", + collateral: true, + debt: false, + token: { + address: + "0x0772131070c7d56f78f3e46b27b70271d8ca81c7c52e3f62aa868fab4b679e43", + balance: "11999999999999999999999", + networkId: "sepolia-alpha", + usdValue: "23851.851851999999999998012345679", + }, + }, + ], + debtPositions: [ + { + id: "7134500b8452fe18a841717346b7afdb", + lending: false, + apy: "0.001000", + group: "3", + collateral: false, + debt: true, + token: { + address: + "0x07809bb63f557736e49ff0ae4a64bd8aa6ea60e3f77f26c520cb92c24e3700d3", + balance: "-1278003947284119364", + networkId: "sepolia-alpha", + usdValue: "-3333.992797477446390835", + }, + }, + { + id: "1134500b8452fe18a841717346b7afdb", + lending: false, + apy: "0.001000", + group: "3", + collateral: false, + debt: true, + token: { + address: + "0x57181b39020af1416747a7d0d2de6ad5a5b721183136585e8774e1425efd5d2", + balance: "-1118003947284119364", + networkId: "sepolia-alpha", + usdValue: "-3276.855766971899953224534371844", + }, + }, + { + id: "4434500b8452fe18a841717346b7afdb", + lending: false, + apy: "0.001000", + group: "3", + collateral: false, + debt: true, + token: { + address: + "0x1fad7c03b2ea7fbef306764e20977f8d4eae6191b3a54e4514cc5fc9d19e569", + balance: "-238003947284119364", + networkId: "sepolia-alpha", + usdValue: "-0.473069574234336068534371844", + }, + }, + { + id: "2334500b8452fe18a841717346b7afdb", + lending: false, + apy: "0.001000", + group: "3", + collateral: false, + debt: true, + token: { + address: + "0x27ef4670397069d7d5442cb7945b27338692de0d8896bdb15e6400cf5249f94", + balance: "-1118003947284119364", + networkId: "sepolia-alpha", + usdValue: "-1117845087471.468471572752257144", + }, + }, + ], + totalUsdValue: "-1117845070230.938253596332937274056398009", + collateralizedPositionsTotalUsdValue: + "23851.851851999999999998012345679", + debtPositionsTotalUsdValue: "14631.04353477817251354", + }, + { + id: "4", + group: "4", + healthRatio: "1.020603", + collateralizedPositions: [ + { + id: "328e835168d876676f4360c32f9f9753", + lending: true, + apy: "0.000000", + group: "3", + collateral: true, + debt: false, + token: { + address: + "0x0772131070c7d56f78f3e46b27b70271d8ca81c7c52e3f62aa868fab4b679e43", + balance: "11999999999999999999999", + networkId: "sepolia-alpha", + usdValue: "23851.851851999999999998012345679", + }, + }, + { + id: "050e37d3d98432c500c743b20c250eb6", + lending: true, + apy: "0.000000", + group: "2", + collateral: true, + debt: false, + token: { + address: + "0x27ef4670397069d7d5442cb7945b27338692de0d8896bdb15e6400cf5249f94", + balance: "1000319500347669370331", + networkId: "sepolia-alpha", + usdValue: "1000177362595112.866171755970450826", + }, + }, + ], + debtPositions: [ + { + id: "7134500b8452fe18a841717346b7afdb", + lending: false, + apy: "0.001000", + group: "3", + collateral: false, + debt: true, + token: { + address: + "0x07809bb63f557736e49ff0ae4a64bd8aa6ea60e3f77f26c520cb92c24e3700d3", + balance: "-1278003947284119364", + networkId: "sepolia-alpha", + usdValue: "-3333.992797477446390835", + }, + }, + { + id: "1134500b8452fe18a841717346b7afdb", + lending: false, + apy: "0.001000", + group: "3", + collateral: false, + debt: true, + token: { + address: + "0x57181b39020af1416747a7d0d2de6ad5a5b721183136585e8774e1425efd5d2", + balance: "-1118003947284119364", + networkId: "sepolia-alpha", + usdValue: "-3276.855766971899953224534371844", + }, + }, + { + id: "4434500b8452fe18a841717346b7afdb", + lending: false, + apy: "0.001000", + group: "3", + collateral: false, + debt: true, + token: { + address: + "0x1fad7c03b2ea7fbef306764e20977f8d4eae6191b3a54e4514cc5fc9d19e569", + balance: "-238003947284119364", + networkId: "sepolia-alpha", + usdValue: "-0.473069574234336068534371844", + }, + }, + { + id: "2334500b8452fe18a841717346b7afdb", + lending: false, + apy: "0.001000", + group: "3", + collateral: false, + debt: true, + token: { + address: + "0x27ef4670397069d7d5442cb7945b27338692de0d8896bdb15e6400cf5249f94", + balance: "-1118003947284119364", + networkId: "sepolia-alpha", + usdValue: "-1117845087471.468471572752257144", + }, + }, + ], + totalUsdValue: "999059517524881.927918159637513551943601991", + collateralizedPositionsTotalUsdValue: + "23851.851851999999999998012345679", + debtPositionsTotalUsdValue: "14631.04353477817251354", + }, + ], + groups: { + "1": { + healthRatio: "2.713544", + }, + "2": { + healthRatio: "4.873483", + }, + "3": { + healthRatio: "1.020603", + }, + "4": { + healthRatio: "0.020603", + }, + }, + totalUsdValue: "1998119068920423.869170596865298046994208382", + }, + ], + totalUsdValue: "1998119070181068.144974281883956248994208382", + }, + { + dappId: "49007844-7a78-4185-be2f-b06bf6fc26a5", + products: [ + { + type: "staking", + manageUrl: + "https://app.avnu.fi/?tokenFrom=0x49d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7&tokenTo=0x0703911a6196ef674fc635de02763dcd4ccc16d7cf736f68a9483cc44eccaa94", + name: "Liquid Staking", + positions: [ + { + id: "937a82be8c5c92d615fd851c82dc6f9f", + apy: "0.030927", + token: { + address: + "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + balance: "17623790831667140", + networkId: "sepolia-alpha", + usdValue: "46.1688389077225386274691", + }, + }, + ], + totalUsdValue: "46.1688389077225386274691", + }, + ], + totalUsdValue: "46.1688389077225386274691", + }, + { + dappId: "2546e5d1-d806-4672-bf48-84d62cb95e21", + products: [ + { + type: "concentratedLiquidityPosition", + manageUrl: "https://app.uniswap.org/#/swap", + name: "Concentrated liquidity", + positions: [ + { + id: "050195914b6bb85dce82f3222c12154c", + poolFeePercentage: "0.05", + tickSpacingPercentage: "0.1", + token0: { + address: + "0x57181b39020af1416747a7d0d2de6ad5a5b721183136585e8774e1425efd5d2", + networkId: "sepolia-alpha", + principal: "1102394379714798486", + accruedFees: "26047894253067246", + minPrice: "0.000424331267348977", + maxPrice: "0.000438129454574905", + currentPrice: "0.000383264801039327", + balance: "1128442273967865732", + usdValue: "3307.450373613730023165357627972", + }, + token1: { + address: + "0x49d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + networkId: "sepolia-alpha", + principal: "0", + accruedFees: "11107133261362", + minPrice: "2282.430431367024993961", + maxPrice: "2356.649337314997410431", + currentPrice: "2609.16211790967372508", + balance: "11107133261362", + usdValue: "0.02909722721793787238503", + }, + totalUsdValue: "3307.479470840947961037742657972", + }, + { + id: "a7b7ebede0d38d62542727df7c1c2cfb", + poolFeePercentage: "0.05", + tickSpacingPercentage: "0.1", + token0: { + address: + "0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d", + networkId: "sepolia-alpha", + principal: "0", + accruedFees: "158995656321138", + minPrice: "2487.493917", + maxPrice: "2568.380972", + currentPrice: "2604.665835", + balance: "158995656321138", + usdValue: "0.000316028403306940909337298", + }, + token1: { + address: + "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + networkId: "sepolia-alpha", + principal: "5066944", + accruedFees: "402659", + minPrice: "0.000389350338108608", + maxPrice: "0.000402011033360373", + currentPrice: "0.000383926408709454", + balance: "5469603", + usdValue: "0.000000014328655066788945", + }, + totalUsdValue: "0.000316042731962007698282298", + }, + ], + totalUsdValue: "3307.47978688367992304544094027", + }, + ], + totalUsdValue: "3307.47978688367992304544094027", + }, + ] diff --git a/packages/extension/src/shared/defiDecomposition/__fixtures__/stakingPositions.ts b/packages/extension/src/shared/defiDecomposition/__fixtures__/stakingPositions.ts new file mode 100644 index 000000000..2a4e390ae --- /dev/null +++ b/packages/extension/src/shared/defiDecomposition/__fixtures__/stakingPositions.ts @@ -0,0 +1,25 @@ +import type { ApiDefiDecompositionProduct } from "@argent/x-shared" + +export const stakingPositions: ApiDefiDecompositionProduct = { + productId: "01a7b83d-d4c4-4109-af0d-ad5b15ab712b", + name: "Staking", + manageUrl: + "https://app.avnu.fi/?tokenFrom=0x49d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7&tokenTo=0x0703911a6196ef674fc635de02763dcd4ccc16d7cf736f68a9483cc44eccaa94", + positions: [ + { + id: "937a82be8c5c92d615fd851c82dc6f9f", + investmentId: "f77d6ce4-a799-475d-920d-c76734e2f748", + tokenAddress: + "0x0703911a6196ef674fc635de02763dcd4ccc16d7cf736f68a9483cc44eccaa94", + totalBalances: { + "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7": + "17431798489347591", + }, + data: { + apy: "0.03192", + totalApy: "0.03192", + }, + }, + ], + type: "staking", +} diff --git a/packages/extension/src/shared/defiDecomposition/__fixtures__/strkDelegatedStakingPositions.ts b/packages/extension/src/shared/defiDecomposition/__fixtures__/strkDelegatedStakingPositions.ts new file mode 100644 index 000000000..817a9ce87 --- /dev/null +++ b/packages/extension/src/shared/defiDecomposition/__fixtures__/strkDelegatedStakingPositions.ts @@ -0,0 +1,30 @@ +import type { ApiDefiDecompositionProduct } from "@argent/x-shared" + +export const strkDelegatedStakingPositions: ApiDefiDecompositionProduct = { + productId: "5d4c7689-6759-4051-a6a2-baf4a397b081", + name: "Starknet Delegated Staking", + manageUrl: "todo", + positions: [ + { + id: "7a4ba7fd747c1abd11d8b77345da8005", + investmentId: "6d2f225a-2bda-4973-a564-d127434da0c2", + totalBalances: { + "0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d": + "9999999900000000", + }, + data: { + stakerInfo: { + name: "Voyager", + iconUrl: + "https://www.dappland.com/dapps/voyager/dapp-icon-voyager.png", + address: + "0x7aa2d3f4d79ed1c2183969b353f4f678559ca72a65dae207395a247c968af93", + }, + stakedAmount: "9999999900000000", + accruedRewards: "0", + apy: "0.015014", + }, + }, + ], + type: "strkDelegatedStaking", +} diff --git a/packages/extension/src/shared/defiDecomposition/__fixtures__/tokenPrices.ts b/packages/extension/src/shared/defiDecomposition/__fixtures__/tokenPrices.ts new file mode 100644 index 000000000..fb2b63b22 --- /dev/null +++ b/packages/extension/src/shared/defiDecomposition/__fixtures__/tokenPrices.ts @@ -0,0 +1,84 @@ +import type { Address } from "@argent/x-shared" + +export const tokenPrices = [ + { + address: + "0x27ef4670397069d7d5442cb7945b27338692de0d8896bdb15e6400cf5249f94" as Address, + pricingId: 12, + ethValue: "0.000383828292477514", + ccyValue: "0.999857907646", + ethDayChange: "-0.010714290537861281", + ccyDayChange: "0.000166", + networkId: "sepolia-alpha", + }, + { + address: + "0x63d32a3fa6074e72e7a1e06fe78c46a0c8473217773e19f11d8c8cbfc4ff8ca" as Address, + pricingId: 30, + ethValue: "0.059876543210987654", + ccyValue: "156.123456789", + ethDayChange: "0.005432109876543210", + ccyDayChange: "14.567890", + networkId: "sepolia-alpha", + }, + { + address: + "0x7809bb63f557736e49ff0ae4a64bd8aa6ea60e3f77f26c520cb92c24e3700d3" as Address, + pricingId: 1, + ethValue: "1.000000000000000000", + ccyValue: "2608.750000000", + ethDayChange: "0.000000000000000000", + ccyDayChange: "-12.345678", + networkId: "sepolia-alpha", + }, + { + address: + "0x57181b39020af1416747a7d0d2de6ad5a5b721183136585e8774e1425efd5d2" as Address, + pricingId: 44, + ethValue: "1.123456789012345678", + ccyValue: "2930.987654321", + ethDayChange: "0.009876543210987654", + ccyDayChange: "25.678901", + networkId: "sepolia-alpha", + }, + { + address: + "0x772131070c7d56f78f3e46b27b70271d8ca81c7c52e3f62aa868fab4b679e43" as Address, + pricingId: 42, + ethValue: "0.000765432109876543", + ccyValue: "1.987654321", + ethDayChange: "-0.001234567890123456", + ccyDayChange: "-0.098765", + networkId: "sepolia-alpha", + }, + { + address: + "0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d" as Address, + pricingId: 43, + ethValue: "0.000765432109876543", + ccyValue: "1.987654321", + ethDayChange: "-0.001234567890123456", + ccyDayChange: "-0.098765", + networkId: "sepolia-alpha", + }, + { + address: + "0x49d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7" as Address, + pricingId: 1, + ethValue: "1", + ccyValue: "2619.688315", + ethDayChange: "0", + ccyDayChange: "0.002173", + networkId: "sepolia-alpha", + }, + { + address: + "0x01fad7c03b2ea7fbef306764e20977f8d4eae6191b3a54e4514cc5fc9d19e569" as Address, + pricingId: 67, + ethValue: "0.000765432109876543", + ccyValue: "1.987654321", + ethDayChange: "-0.001234567890123456", + ccyDayChange: "-0.098765", + networkId: "sepolia-alpha", + }, +] diff --git a/packages/extension/src/shared/defiDecomposition/__fixtures__/tokens.ts b/packages/extension/src/shared/defiDecomposition/__fixtures__/tokens.ts new file mode 100644 index 000000000..065295ec0 --- /dev/null +++ b/packages/extension/src/shared/defiDecomposition/__fixtures__/tokens.ts @@ -0,0 +1,121 @@ +import type { Token } from "../../token/__new/types/token.model" + +export const tokens: Token[] = [ + { + id: 6460, + address: + "0x27ef4670397069d7d5442cb7945b27338692de0d8896bdb15e6400cf5249f94", + name: "usd-coin", + symbol: "USDC", + decimals: 6, + iconUrl: "https://dv3jj1unlp2jl.cloudfront.net/128/color/usdc.png", + popular: false, + tradable: false, + pricingId: 12, + networkId: "sepolia-alpha", + }, + { + id: 6459, + address: + "0x63d32a3fa6074e72e7a1e06fe78c46a0c8473217773e19f11d8c8cbfc4ff8ca", + name: "wrapped-bitcoin", + symbol: "WBTC", + decimals: 8, + iconUrl: "https://dv3jj1unlp2jl.cloudfront.net/128/color/wbtc.png", + popular: false, + tradable: false, + pricingId: 30, + networkId: "sepolia-alpha", + }, + { + id: 6458, + address: + "0x7809bb63f557736e49ff0ae4a64bd8aa6ea60e3f77f26c520cb92c24e3700d3", + name: "ethereum", + symbol: "ETH", + decimals: 18, + iconUrl: "https://dv3jj1unlp2jl.cloudfront.net/128/color/eth.png", + popular: false, + tradable: false, + pricingId: 1, + networkId: "sepolia-alpha", + }, + { + id: 6459, + address: + "0x63d32a3fa6074e72e7a1e06fe78c46a0c8473217773e19f11d8c8cbfc4ff8ca", + name: "wrapped-bitcoin", + symbol: "WBTC", + decimals: 8, + iconUrl: "https://dv3jj1unlp2jl.cloudfront.net/128/color/wbtc.png", + popular: false, + tradable: false, + pricingId: 30, + networkId: "sepolia-alpha", + }, + { + id: 6462, + address: + "0x57181b39020af1416747a7d0d2de6ad5a5b721183136585e8774e1425efd5d2", + name: "wrapped-steth", + symbol: "wstETH", + decimals: 18, + iconUrl: "https://dv3jj1unlp2jl.cloudfront.net/128/color/steth.png", + popular: false, + tradable: false, + pricingId: 44, + networkId: "sepolia-alpha", + }, + { + id: 63, + address: + "0x772131070c7d56f78f3e46b27b70271d8ca81c7c52e3f62aa868fab4b679e43", + name: "starknet STRK unofficial", + symbol: "STRK", + decimals: 18, + iconUrl: "https://dv3jj1unlp2jl.cloudfront.net/128/color/strk.png", + popular: false, + tradable: false, + pricingId: 42, + networkId: "sepolia-alpha", + }, + + { + id: 6463, + address: + "0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d", + name: "starknet", + symbol: "STRK", + decimals: 18, + iconUrl: "https://dv3jj1unlp2jl.cloudfront.net/128/color/strk.png", + popular: false, + tradable: false, + pricingId: 43, + networkId: "sepolia-alpha", + }, + { + id: 5226, + address: + "0x01fad7c03b2ea7fbef306764e20977f8d4eae6191b3a54e4514cc5fc9d19e569", + name: "Ekubo Protocol", + symbol: "EKUBO", + decimals: 18, + iconUrl: "https://dv3jj1unlp2jl.cloudfront.net/128/color/ekubo.png", + popular: false, + tradable: false, + pricingId: 67, + networkId: "sepolia-alpha", + }, + { + id: 1, + address: + "0x49d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + brandColor: "#627EEA", + name: "Ether", + symbol: "ETH", + decimals: 18, + iconUrl: "https://dv3jj1unlp2jl.cloudfront.net/128/color/eth.png", + pricingId: 1, + networkId: "sepolia-alpha", + }, +] diff --git a/packages/extension/src/shared/defiDecomposition/__fixtures__/tokensInfo.ts b/packages/extension/src/shared/defiDecomposition/__fixtures__/tokensInfo.ts new file mode 100644 index 000000000..a2ebe04cf --- /dev/null +++ b/packages/extension/src/shared/defiDecomposition/__fixtures__/tokensInfo.ts @@ -0,0 +1,168 @@ +import type { Address } from "@argent/x-shared" + +export const tokensInfo = [ + { + id: 6460, + address: + "0x27ef4670397069d7d5442cb7945b27338692de0d8896bdb15e6400cf5249f94" as Address, + name: "usd-coin", + symbol: "USDC", + decimals: 6, + iconUrl: "https://dv3jj1unlp2jl.cloudfront.net/128/color/usdc.png", + sendable: true, + popular: false, + refundable: false, + listed: true, + tradable: false, + category: "tokens" as const, + pricingId: 12, + networkId: "sepolia-alpha", + updatedAt: 1729087684171, + }, + { + id: 6459, + address: + "0x63d32a3fa6074e72e7a1e06fe78c46a0c8473217773e19f11d8c8cbfc4ff8ca" as Address, + name: "wrapped-bitcoin", + symbol: "WBTC", + decimals: 8, + iconUrl: "https://dv3jj1unlp2jl.cloudfront.net/128/color/wbtc.png", + sendable: true, + popular: false, + refundable: false, + listed: true, + tradable: false, + category: "tokens" as const, + pricingId: 30, + networkId: "sepolia-alpha", + updatedAt: 1729087684172, + }, + { + id: 6458, + address: + "0x7809bb63f557736e49ff0ae4a64bd8aa6ea60e3f77f26c520cb92c24e3700d3" as Address, + name: "ethereum", + symbol: "ETH", + decimals: 18, + iconUrl: "https://dv3jj1unlp2jl.cloudfront.net/128/color/eth.png", + sendable: true, + popular: false, + refundable: false, + listed: true, + tradable: false, + category: "tokens" as const, + pricingId: 1, + networkId: "sepolia-alpha", + updatedAt: 1729087983021, + }, + { + id: 6459, + address: + "0x63d32a3fa6074e72e7a1e06fe78c46a0c8473217773e19f11d8c8cbfc4ff8ca" as Address, + name: "wrapped-bitcoin", + symbol: "WBTC", + decimals: 8, + iconUrl: "https://dv3jj1unlp2jl.cloudfront.net/128/color/wbtc.png", + sendable: true, + popular: false, + refundable: false, + listed: true, + tradable: false, + category: "tokens" as const, + pricingId: 30, + networkId: "sepolia-alpha", + updatedAt: 1729087983021, + }, + { + id: 6462, + address: + "0x57181b39020af1416747a7d0d2de6ad5a5b721183136585e8774e1425efd5d2" as Address, + name: "wrapped-steth", + symbol: "wstETH", + decimals: 18, + iconUrl: "https://dv3jj1unlp2jl.cloudfront.net/128/color/steth.png", + sendable: true, + popular: false, + refundable: false, + listed: true, + tradable: false, + category: "tokens" as const, + pricingId: 44, + networkId: "sepolia-alpha", + updatedAt: 1729087983020, + }, + { + id: 63, + address: + "0x772131070c7d56f78f3e46b27b70271d8ca81c7c52e3f62aa868fab4b679e43" as Address, + name: "starknet STRK unofficial", + symbol: "STRK", + decimals: 18, + iconUrl: "https://dv3jj1unlp2jl.cloudfront.net/128/color/strk.png", + sendable: true, + popular: false, + refundable: false, + listed: false, + tradable: false, + category: "tokens" as const, + pricingId: 42, + networkId: "sepolia-alpha", + updatedAt: 1729087983020, + }, + { + id: 6463, + address: + "0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d" as Address, + name: "starknet", + symbol: "STRK", + decimals: 18, + iconUrl: "https://dv3jj1unlp2jl.cloudfront.net/128/color/strk.png", + sendable: true, + popular: false, + refundable: false, + listed: false, + tradable: false, + category: "tokens" as const, + pricingId: 43, + networkId: "sepolia-alpha", + updatedAt: 1729087983020, + }, + { + id: 1, + address: + "0x49d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7" as Address, + brandColor: "#627EEA", + name: "Ether", + symbol: "ETH", + decimals: 18, + iconUrl: "https://dv3jj1unlp2jl.cloudfront.net/128/color/eth.png", + sendable: true, + popular: true, + refundable: false, + listed: true, + tradable: true, + category: "tokens" as const, + pricingId: 1, + networkId: "sepolia-alpha", + updatedAt: 1729154284011, + }, + { + id: 5226, + address: + "0x1fad7c03b2ea7fbef306764e20977f8d4eae6191b3a54e4514cc5fc9d19e569" as Address, + name: "Ekubo Protocol", + symbol: "EKUBO", + decimals: 18, + iconUrl: "https://dv3jj1unlp2jl.cloudfront.net/128/color/ekubo.png", + sendable: true, + popular: false, + refundable: false, + listed: true, + tradable: false, + category: "tokens" as const, + pricingId: 67, + networkId: "sepolia-alpha", + updatedAt: 1729175045909, + tags: [], + }, +] diff --git a/packages/extension/src/shared/defiDecomposition/getPositionTokenBalance.ts b/packages/extension/src/shared/defiDecomposition/getPositionTokenBalance.ts new file mode 100644 index 000000000..9d3c95ad7 --- /dev/null +++ b/packages/extension/src/shared/defiDecomposition/getPositionTokenBalance.ts @@ -0,0 +1,15 @@ +import type { BaseTokenWithBalance } from "../token/__new/types/tokenBalance.model" +import type { BaseWalletAccount } from "../wallet.model" +import type { PositionBaseToken } from "./schema" + +export const getPositionTokenBalance = ( + account: BaseWalletAccount, + positionToken: PositionBaseToken, +): BaseTokenWithBalance => { + return { + account: account.address, + address: positionToken.address, + balance: positionToken.balance, + networkId: positionToken.networkId, + } +} diff --git a/packages/extension/src/shared/defiDecomposition/helpers/computeCollateralizedDebtPositionsUsdValue.test.ts b/packages/extension/src/shared/defiDecomposition/helpers/computeCollateralizedDebtPositionsUsdValue.test.ts new file mode 100644 index 000000000..29aa1f596 --- /dev/null +++ b/packages/extension/src/shared/defiDecomposition/helpers/computeCollateralizedDebtPositionsUsdValue.test.ts @@ -0,0 +1,116 @@ +import { bigDecimal } from "@argent/x-shared" +import { getMockAccount } from "../../../../test/account.mock" +import { collateralizedDebtPositions } from "../__fixtures__/collateralizedDebtPositions" +import { tokenPrices } from "../__fixtures__/tokenPrices" +import { tokens } from "../__fixtures__/tokens" +import type { + ParsedCollateralizedDebtBorrowingPosition, + ParsedCollateralizedDebtBorrowingPositionWithUsdValue, + ParsedCollateralizedDebtLendingPosition, +} from "../schema" +import { + computeCollateralizedDebtBorrowingPositionUsdValue, + computeCollateralizedDebtBorrowingPositionsUsdValue, + computeCollateralizedDebtLendingPositionUsdValue, +} from "./computeCollateralizedDebtPositionsUsdValue" +import { parseCollateralizedDebtPositions } from "./parseCollateralizedDebtPositions" + +describe("computeCollateralizedDebtPositionsUsdValue", () => { + const mockAccount = getMockAccount({ + address: "0x123", + networkId: "sepolia-alpha", + }) + + let lendingPositions: ParsedCollateralizedDebtLendingPosition[] + let borrowingPositions: ParsedCollateralizedDebtBorrowingPosition[] + + beforeEach(async () => { + const parsed = parseCollateralizedDebtPositions( + collateralizedDebtPositions, + mockAccount, + ) + + lendingPositions = parsed.lending + borrowingPositions = parsed.borrowing + }) + + afterEach(async () => { + vi.clearAllMocks() + }) + + describe("computeCollateralizedDebtPositionUsdValue", () => { + it("should compute USD value for a collateralized debt lending position", async () => { + const result = computeCollateralizedDebtLendingPositionUsdValue( + lendingPositions[0], + tokens, + tokenPrices, + ) + expect(result).toBeDefined() + expect(result?.token.usdValue).toBeDefined() + expect(result?.token.usdValue).not.toBe("0") + expect(result?.token.usdValue).toEqual("7998.863360153932856954") + }) + }) + + describe("computeCollateralizedDebtBorrowingPositionUsdValue", () => { + it("should compute USD value for a collateralized debt borrowing position", async () => { + const result = computeCollateralizedDebtBorrowingPositionUsdValue( + borrowingPositions[0], + tokens, + tokenPrices, + ) + + expect(result).toBeDefined() + expect(result?.totalUsdValue).toBeDefined() + expect(result?.totalUsdValue).not.toBe("0") + const collateralizedTotal = bigDecimal.parseUnits( + result?.collateralizedPositionsTotalUsdValue || "0", + ) + const debtTotal = bigDecimal.parseUnits( + result?.debtPositionsTotalUsdValue || "0", + ) + const total = bigDecimal.formatUnits( + bigDecimal.sub(collateralizedTotal, debtTotal), + ) + expect(total).toEqual(result?.totalUsdValue) + expect(result?.collateralizedPositions[0].token.usdValue).toBeDefined() + expect(result?.debtPositions[0].token.usdValue).toBeDefined() + expect(result?.collateralizedPositions[0].token.usdValue).toEqual( + "20870000.00002119501412663875", + ) + expect(result?.debtPositions[0].token.usdValue).toEqual( + "14630.99895528632098248", + ) + }) + }) + + describe("computeCollateralizedDebtBorrowingPositionsUsdValue", () => { + it("should compute USD value for all collateralized debt borrowing positions", async () => { + const result = computeCollateralizedDebtBorrowingPositionsUsdValue( + borrowingPositions, + tokens, + tokenPrices, + ) + + expect(result).toBeDefined() + expect(result.totalUsdValue).toBeDefined() + expect(result.totalUsdValue).not.toBe("0") + expect(result.totalUsdValue).toEqual( + "33891190.830367053823960818137282085", + ) + + const firstGroupPosition = result + .positions[0] as ParsedCollateralizedDebtBorrowingPositionWithUsdValue + expect( + firstGroupPosition.collateralizedPositions[0].token.usdValue, + ).toBeDefined() + expect(firstGroupPosition.debtPositions[0].token.usdValue).toBeDefined() + expect( + firstGroupPosition.collateralizedPositions[0].token.usdValue, + ).toEqual("20870000.00002119501412663875") + expect(firstGroupPosition.debtPositions[0].token.usdValue).toEqual( + "14630.99895528632098248", + ) + }) + }) +}) diff --git a/packages/extension/src/shared/defiDecomposition/helpers/computeCollateralizedDebtPositionsUsdValue.ts b/packages/extension/src/shared/defiDecomposition/helpers/computeCollateralizedDebtPositionsUsdValue.ts new file mode 100644 index 000000000..53526bd4e --- /dev/null +++ b/packages/extension/src/shared/defiDecomposition/helpers/computeCollateralizedDebtPositionsUsdValue.ts @@ -0,0 +1,167 @@ +import type { BigDecimal, Token } from "@argent/x-shared" +import { bigDecimal } from "@argent/x-shared" +import type { TokenPriceDetails } from "../../token/__new/types/tokenPrice.model" +import type { + ParsedCollateralizedDebtBorrowingPosition, + ParsedCollateralizedDebtBorrowingPositionWithUsdValue, + ParsedCollateralizedDebtLendingPosition, + ParsedCollateralizedDebtPositionsWithUsdValue, + ParsedCollateralizedDebtPositionWithUsdValue, +} from "../schema" +import { computeUsdValueForPosition } from "./computeUsdValueForPosition" +import { sortDescendingByUsdValue } from "./sortDescendingByUsdValue" + +export const computeCollateralizedDebtLendingPositionUsdValue = ( + position: ParsedCollateralizedDebtLendingPosition, + tokens: Token[], + tokenPrices: TokenPriceDetails[], +): ParsedCollateralizedDebtPositionWithUsdValue | undefined => { + const usdValue = computeUsdValueForPosition( + position.token, + tokens, + tokenPrices, + ) + + if (!usdValue) { + return + } + + return { + ...position, + token: { + ...position.token, + usdValue, + }, + } +} + +export const computeCollateralizedDebtBorrowingPositionUsdValue = ( + position: ParsedCollateralizedDebtBorrowingPosition, + tokens: Token[], + tokenPrices: TokenPriceDetails[], +): ParsedCollateralizedDebtBorrowingPositionWithUsdValue | undefined => { + let collateralizedPositionsTotalUsdValue: BigDecimal = + bigDecimal.parseUnits("0") + let debtPositionsTotalUsdValue: BigDecimal = bigDecimal.parseUnits("0") + + const collateralizedPositions = position.collateralizedPositions.map( + (position) => { + const collateralizedPosition = + computeCollateralizedDebtLendingPositionUsdValue( + position, + tokens, + tokenPrices, + ) + collateralizedPositionsTotalUsdValue = bigDecimal.add( + collateralizedPositionsTotalUsdValue, + bigDecimal.parseUnits(collateralizedPosition?.token.usdValue || "0"), + ) + return collateralizedPosition + }, + ) + const debtPositions = position.debtPositions.map((position) => { + const debtPosition = computeCollateralizedDebtLendingPositionUsdValue( + position, + tokens, + tokenPrices, + ) + debtPositionsTotalUsdValue = bigDecimal.add( + debtPositionsTotalUsdValue, + bigDecimal.parseUnits(debtPosition?.token.usdValue || "0"), + ) + return debtPosition + }) + + const filteredCollateralizedPositions = collateralizedPositions.filter( + (position) => position !== undefined, + ) + + const filteredDebtPositions = debtPositions.filter( + (position) => position !== undefined, + ) + + if ( + !filteredCollateralizedPositions.length || + !filteredDebtPositions.length + ) { + return + } + + const totalUsdValue: BigDecimal = bigDecimal.sub( + collateralizedPositionsTotalUsdValue, + debtPositionsTotalUsdValue, + ) + + return { + ...position, + totalUsdValue: bigDecimal.formatUnits(totalUsdValue), + collateralizedPositionsTotalUsdValue: bigDecimal.formatUnits( + collateralizedPositionsTotalUsdValue, + ), + debtPositionsTotalUsdValue: bigDecimal.formatUnits( + debtPositionsTotalUsdValue, + ), + collateralizedPositions: filteredCollateralizedPositions, + debtPositions: filteredDebtPositions, + } +} + +export const computeCollateralizedDebtLendingPositionsUsdValue = ( + positions: ParsedCollateralizedDebtLendingPosition[], + tokens: Token[], + tokenPrices: TokenPriceDetails[], +): ParsedCollateralizedDebtPositionsWithUsdValue => { + let totalUsdValue: BigDecimal = bigDecimal.parseUnits("0") + const result = + positions + .map((position) => { + const lendingPosition = + computeCollateralizedDebtLendingPositionUsdValue( + position, + tokens, + tokenPrices, + ) + totalUsdValue = bigDecimal.add( + totalUsdValue, + bigDecimal.parseUnits(lendingPosition?.token.usdValue || "0"), + ) + return lendingPosition + }) + .filter((position) => position !== undefined) + .sort(sortDescendingByUsdValue) || [] + + return { + totalUsdValue: bigDecimal.formatUnits(totalUsdValue), + positions: result, + } +} + +export const computeCollateralizedDebtBorrowingPositionsUsdValue = ( + positions: ParsedCollateralizedDebtBorrowingPosition[], + tokens: Token[], + tokenPrices: TokenPriceDetails[], +): ParsedCollateralizedDebtPositionsWithUsdValue => { + let totalUsdValue: BigDecimal = bigDecimal.parseUnits("0") + const result = + positions + .map((position) => { + const borrowingPosition = + computeCollateralizedDebtBorrowingPositionUsdValue( + position, + tokens, + tokenPrices, + ) + totalUsdValue = bigDecimal.add( + totalUsdValue, + bigDecimal.parseUnits(borrowingPosition?.totalUsdValue || "0"), + ) + return borrowingPosition + }) + .filter((position) => position !== undefined) + .sort(sortDescendingByUsdValue) || [] + + return { + totalUsdValue: bigDecimal.formatUnits(totalUsdValue), + positions: result, + } +} diff --git a/packages/extension/src/shared/defiDecomposition/helpers/computeConcentratedLiquidityPositionsUsdValue.test.ts b/packages/extension/src/shared/defiDecomposition/helpers/computeConcentratedLiquidityPositionsUsdValue.test.ts new file mode 100644 index 000000000..f82224dc0 --- /dev/null +++ b/packages/extension/src/shared/defiDecomposition/helpers/computeConcentratedLiquidityPositionsUsdValue.test.ts @@ -0,0 +1,92 @@ +import { getMockAccount } from "../../../../test/account.mock" +import { concentratedLiquidityPositions } from "../__fixtures__/concentratedLiquidityPositions" +import { tokenPrices } from "../__fixtures__/tokenPrices" +import { tokens } from "../__fixtures__/tokens" +import type { ParsedConcentratedLiquidityPosition } from "../schema" +import { + computeConcentratedLiquidityPositionUsdValue, + computeConcentratedLiquidityPositionsUsdValue, +} from "./computeConcentratedLiquidityPositionsUsdValue" +import { parseConcentratedLiquidityPositions } from "./parseConcentratedLiquidityPositions" + +describe("computeConcentratedLiquidityPositionsUsdValue", async () => { + const mockAccount = getMockAccount({ + address: "0x123", + networkId: "sepolia-alpha", + }) + + let parsedPositions: ParsedConcentratedLiquidityPosition[] = [] + + beforeEach(async () => { + parsedPositions = parseConcentratedLiquidityPositions( + concentratedLiquidityPositions, + mockAccount, + tokens, + ) + }) + + afterEach(async () => { + vi.clearAllMocks() + }) + + describe("computeConcentratedLiquidityPositionUsdValue", () => { + it("should compute USD value for a concentrated liquidity position", async () => { + const result = computeConcentratedLiquidityPositionUsdValue( + parsedPositions[0], + tokens, + tokenPrices, + ) + + expect(result).toBeDefined() + expect(result?.totalUsdValue).toBeDefined() + expect(result?.totalUsdValue).not.toBe("0") + expect(result?.totalUsdValue).toEqual("3307.479470840947961037742657972") + expect(result?.token0.usdValue).toBeDefined() + expect(result?.token1.usdValue).toBeDefined() + expect(result?.token0.usdValue).toEqual( + "3307.450373613730023165357627972", + ) + expect(result?.token1.usdValue).toEqual("0.02909722721793787238503") + }) + }) + + describe("computeConcentratedLiquidityPositionsUsdValue", () => { + it("should compute USD value for all concentrated liquidity positions", async () => { + const result = computeConcentratedLiquidityPositionsUsdValue( + parsedPositions, + tokens, + tokenPrices, + ) + + expect(result).toBeDefined() + expect(result.totalUsdValue).toBeDefined() + expect(result.totalUsdValue).not.toBe("0") + expect(result.totalUsdValue).toEqual("3307.895989919979541010882232535") + expect(result.positions).toHaveLength(2) + expect(result.positions[0].totalUsdValue).toBeDefined() + expect(result.positions[0].totalUsdValue).toEqual( + "3307.479470840947961037742657972", + ) + expect(result.positions[0].token0.usdValue).toBeDefined() + expect(result.positions[0].token1.usdValue).toBeDefined() + expect(result.positions[0].token0.usdValue).toEqual( + "3307.450373613730023165357627972", + ) + expect(result.positions[0].token1.usdValue).toEqual( + "0.02909722721793787238503", + ) + expect(result.positions[1].totalUsdValue).toBeDefined() + expect(result.positions[1].totalUsdValue).toEqual( + "0.416519079031579973139574563", + ) + expect(result.positions[1].token0.usdValue).toBeDefined() + expect(result.positions[1].token1.usdValue).toBeDefined() + expect(result.positions[1].token0.usdValue).toEqual( + "0.41651906300024110610247", + ) + expect(result.positions[1].token1.usdValue).toEqual( + "0.000000016031338867037104563", + ) + }) + }) +}) diff --git a/packages/extension/src/shared/defiDecomposition/helpers/computeConcentratedLiquidityPositionsUsdValue.ts b/packages/extension/src/shared/defiDecomposition/helpers/computeConcentratedLiquidityPositionsUsdValue.ts new file mode 100644 index 000000000..f430e351b --- /dev/null +++ b/packages/extension/src/shared/defiDecomposition/helpers/computeConcentratedLiquidityPositionsUsdValue.ts @@ -0,0 +1,72 @@ +import type { BigDecimal } from "@argent/x-shared" +import { bigDecimal } from "@argent/x-shared" +import type { TokenPriceDetails } from "../../token/__new/types/tokenPrice.model" +import type { + ParsedConcentratedLiquidityPosition, + ParsedConcentratedLiquidityPositionWithUsdValue, + ParsedConcentratedLiquidityPositionsWithUsdValue, +} from "../schema" +import type { Token } from "../../token/__new/types/token.model" +import { computeUsdValueForPosition } from "./computeUsdValueForPosition" +import { sortDescendingByUsdValue } from "./sortDescendingByUsdValue" + +export const computeConcentratedLiquidityPositionUsdValue = ( + position: ParsedConcentratedLiquidityPosition, + tokens: Token[], + tokenPrices: TokenPriceDetails[], +): ParsedConcentratedLiquidityPositionWithUsdValue | undefined => { + const { token0, token1 } = position + const token0UsdValue = computeUsdValueForPosition( + position.token0, + tokens, + tokenPrices, + ) + const token1UsdValue = computeUsdValueForPosition( + position.token1, + tokens, + tokenPrices, + ) + + if (!token0UsdValue || !token1UsdValue) { + return + } + + const totalUsdValue = bigDecimal.add( + bigDecimal.parseUnits(token0UsdValue), + bigDecimal.parseUnits(token1UsdValue), + ) + + return { + ...position, + totalUsdValue: bigDecimal.formatUnits(totalUsdValue), + token0: { ...token0, usdValue: token0UsdValue }, + token1: { ...token1, usdValue: token1UsdValue }, + } +} + +export const computeConcentratedLiquidityPositionsUsdValue = ( + positions: ParsedConcentratedLiquidityPosition[], + tokens: Token[], + tokenPrices: TokenPriceDetails[], +): ParsedConcentratedLiquidityPositionsWithUsdValue => { + let totalUsdValue: BigDecimal = bigDecimal.parseUnits("0") + const positionsWithUsdValue = positions.map((position) => { + const positionWithUsdValue = computeConcentratedLiquidityPositionUsdValue( + position, + tokens, + tokenPrices, + ) + totalUsdValue = bigDecimal.add( + totalUsdValue, + bigDecimal.parseUnits(positionWithUsdValue?.totalUsdValue || "0"), + ) + return positionWithUsdValue + }) + + return { + totalUsdValue: bigDecimal.formatUnits(totalUsdValue), + positions: positionsWithUsdValue + .filter((position) => position !== undefined) + .sort(sortDescendingByUsdValue), + } +} diff --git a/packages/extension/src/shared/defiDecomposition/helpers/computeDefiDecompositionUsdValue.test.ts b/packages/extension/src/shared/defiDecomposition/helpers/computeDefiDecompositionUsdValue.test.ts new file mode 100644 index 000000000..b897ae6d1 --- /dev/null +++ b/packages/extension/src/shared/defiDecomposition/helpers/computeDefiDecompositionUsdValue.test.ts @@ -0,0 +1,134 @@ +import { getMockAccount } from "../../../../test/account.mock" +import { defiDecomposition } from "../__fixtures__/defiDecomposition" +import { tokenPrices } from "../__fixtures__/tokenPrices" +import { tokens } from "../__fixtures__/tokens" +import type { + ParsedCollateralizedDebtBorrowingPositionWithUsdValue, + ParsedCollateralizedDebtPositionWithUsdValue, + ParsedDefiDecomposition, + ParsedDelegatedTokensPositionWithUsdValue, + ParsedStakingPositionWithUsdValue, +} from "../schema" +import { + parsedCollateralizedDebtPositionWithUsdValueSchema, + parsedDelegatedTokensPositionWithUsdValueSchema, +} from "../schema" +import { computeDefiDecompositionUsdValue } from "./computeDefiDecompositionUsdValue" +import { parseDefiDecomposition } from "./parseDefiDecomposition" + +describe("computeDefiDecompositionUsdValue", () => { + const mockAccount = getMockAccount({ + address: "0x123", + networkId: "sepolia-alpha", + }) + let parsedDefiDecomposition: ParsedDefiDecomposition + + beforeEach(() => { + parsedDefiDecomposition = parseDefiDecomposition( + defiDecomposition, + mockAccount, + tokens, + ) + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + it("should compute USD value for the entire defi decomposition and sort by balance", async () => { + const result = computeDefiDecompositionUsdValue( + parsedDefiDecomposition, + tokens, + tokenPrices, + ) + + expect(result).toBeDefined() + expect(result).toHaveLength(parsedDefiDecomposition.length) + + // Check Vesu (collateralizedDebtPosition) + const vesuDapp = result[0] + expect(vesuDapp.products).toHaveLength(2) + const vesuLendingProduct = vesuDapp.products[1] + expect(vesuLendingProduct.positions).toHaveLength(2) + const vesuBorrowingProduct = vesuDapp.products[0] + expect(vesuBorrowingProduct.positions).toHaveLength(3) + expect(vesuDapp.totalUsdValue).toEqual( + "35151822.148192485162538308119350079", + ) + vesuLendingProduct.positions.forEach((position, index) => { + expect( + parsedCollateralizedDebtPositionWithUsdValueSchema.safeParse(position) + .success, + ).toBe(true) + + if (index === 1) { + const collateralizedDebtPosition = + position as ParsedCollateralizedDebtPositionWithUsdValue + expect(collateralizedDebtPosition.token.usdValue).toEqual( + "7998.868427433808806882", + ) + } else if (index === 0) { + const collateralizedDebtPosition = + position as ParsedCollateralizedDebtPositionWithUsdValue + expect(collateralizedDebtPosition.token.usdValue).toEqual( + "1252645.40737625120985132", + ) + } + }) + vesuBorrowingProduct.positions.forEach((position, index) => { + if (index === 0) { + const collateralizedDebtPosition = + position as ParsedCollateralizedDebtBorrowingPositionWithUsdValue + expect( + collateralizedDebtPosition.collateralizedPositions[0].token.usdValue, + ).toBeDefined() + expect( + collateralizedDebtPosition.debtPositions[0].token.usdValue, + ).toBeDefined() + expect( + collateralizedDebtPosition.collateralizedPositions[0].token.usdValue, + ).toEqual("20870000.0012132577431737975") + expect( + collateralizedDebtPosition.debtPositions[0].token.usdValue, + ).toEqual("14631.04353477817251354") + } + }) + + // Check Avnu (staking) + const avnuDapp = result[1] + expect(avnuDapp.products).toHaveLength(1) + const avnuProduct = avnuDapp.products[0] + expect(avnuDapp.totalUsdValue).toEqual("46.1688389077225386274691") + const avnuPosition = avnuProduct + .positions[0] as ParsedStakingPositionWithUsdValue + expect(avnuProduct.positions).toHaveLength(1) + expect(avnuPosition.token.usdValue).toBeDefined() + expect(avnuPosition.token.usdValue).not.toBe("0") + expect(avnuPosition.token.usdValue).toEqual("46.1688389077225386274691") + + // Check Ekubo (delegatedTokens) + const ekuboDapp = result[2] + expect(ekuboDapp.products).toHaveLength(1) + const ekuboProduct = ekuboDapp.products[0] + expect(ekuboProduct.positions).toHaveLength(2) + ekuboProduct.positions.forEach((position, index) => { + expect( + parsedDelegatedTokensPositionWithUsdValueSchema.safeParse(position) + .success, + ).toBe(true) + const delegatedTokensPosition = + position as ParsedDelegatedTokensPositionWithUsdValue + if (index === 0) { + expect(delegatedTokensPosition.token.usdValue).toEqual( + "1.050005476669488196418579885", + ) + } else if (index === 1) { + expect(delegatedTokensPosition.token.usdValue).toEqual( + "0.058365966678547907384014494", + ) + } + }) + + expect(ekuboDapp.totalUsdValue).toEqual("1.108371443348036103802594379") + }) +}) diff --git a/packages/extension/src/shared/defiDecomposition/helpers/computeDefiDecompositionUsdValue.ts b/packages/extension/src/shared/defiDecomposition/helpers/computeDefiDecompositionUsdValue.ts new file mode 100644 index 000000000..e8fd29ed2 --- /dev/null +++ b/packages/extension/src/shared/defiDecomposition/helpers/computeDefiDecompositionUsdValue.ts @@ -0,0 +1,151 @@ +import type { BigDecimal, Token } from "@argent/x-shared" +import { bigDecimal } from "@argent/x-shared" +import type { + ParsedCollateralizedDebtBorrowingPosition, + ParsedCollateralizedDebtLendingPosition, + ParsedConcentratedLiquidityPosition, + ParsedDefiDecomposition, + ParsedDefiDecompositionWithUsdValue, + ParsedDelegatedTokensPosition, + ParsedProduct, + ParsedProductWithUsdValue, + ParsedStakingPosition, + ParsedStrkDelegatedStakingPosition, +} from "../schema" +import { + computeCollateralizedDebtBorrowingPositionsUsdValue, + computeCollateralizedDebtLendingPositionsUsdValue, +} from "./computeCollateralizedDebtPositionsUsdValue" +import { computeConcentratedLiquidityPositionsUsdValue } from "./computeConcentratedLiquidityPositionsUsdValue" +import { computeDelegatedTokensPositionsUsdValue } from "./computeDelegatedTokensPositionsUsdValue" +import { computeStakingPositionsUsdValue } from "./computeStakingPositionsUsdValue" +import { computeStrkDelegatedStakingPositionsUsdValue } from "./computeStrkDelegatedStakingPositionsUsdValue" +import type { TokenPriceDetails } from "../../token/__new/types/tokenPrice.model" +import { sortDescendingByUsdValue } from "./sortDescendingByUsdValue" + +const computeProductUsdValue = ( + product: ParsedProduct, + tokens: Token[], + tokenPrices: TokenPriceDetails[], +): ParsedProductWithUsdValue => { + let positionsWithUsdValue + let totalUsdValue: BigDecimal = bigDecimal.parseUnits("0") + switch (product.type) { + case "concentratedLiquidityPosition": + positionsWithUsdValue = computeConcentratedLiquidityPositionsUsdValue( + product.positions as ParsedConcentratedLiquidityPosition[], + tokens, + tokenPrices, + ) + totalUsdValue = bigDecimal.add( + totalUsdValue, + bigDecimal.parseUnits(positionsWithUsdValue.totalUsdValue), + ) + break + case "collateralizedDebtLendingPosition": + positionsWithUsdValue = computeCollateralizedDebtLendingPositionsUsdValue( + product.positions as ParsedCollateralizedDebtLendingPosition[], + tokens, + tokenPrices, + ) + totalUsdValue = bigDecimal.add( + totalUsdValue, + bigDecimal.parseUnits(positionsWithUsdValue.totalUsdValue), + ) + break + case "collateralizedDebtBorrowingPosition": + positionsWithUsdValue = + computeCollateralizedDebtBorrowingPositionsUsdValue( + product.positions as ParsedCollateralizedDebtBorrowingPosition[], + tokens, + tokenPrices, + ) + totalUsdValue = bigDecimal.add( + totalUsdValue, + bigDecimal.parseUnits(positionsWithUsdValue.totalUsdValue), + ) + break + case "delegatedTokens": + positionsWithUsdValue = computeDelegatedTokensPositionsUsdValue( + product.positions as ParsedDelegatedTokensPosition[], + tokens, + tokenPrices, + ) + totalUsdValue = bigDecimal.add( + totalUsdValue, + bigDecimal.parseUnits(positionsWithUsdValue.totalUsdValue), + ) + break + case "strkDelegatedStaking": + positionsWithUsdValue = computeStrkDelegatedStakingPositionsUsdValue( + product.positions as ParsedStrkDelegatedStakingPosition[], + tokens, + tokenPrices, + ) + + totalUsdValue = bigDecimal.add( + totalUsdValue, + bigDecimal.parseUnits(positionsWithUsdValue.totalUsdValue), + ) + break + case "staking": + positionsWithUsdValue = computeStakingPositionsUsdValue( + product.positions as ParsedStakingPosition[], + tokens, + tokenPrices, + ) + totalUsdValue = bigDecimal.add( + totalUsdValue, + bigDecimal.parseUnits(positionsWithUsdValue.totalUsdValue), + ) + break + default: + positionsWithUsdValue = { totalUsdValue: "0", positions: [] } + } + + return { + ...product, + positions: positionsWithUsdValue.positions, + totalUsdValue: bigDecimal.formatUnits(totalUsdValue), + } +} + +export const computeDefiDecompositionUsdValue = ( + defiDecomposition: ParsedDefiDecomposition, + tokens: Token[], + tokenPrices: TokenPriceDetails[], +): ParsedDefiDecompositionWithUsdValue => { + const dappsWithUsdValue = defiDecomposition.map((dapp) => { + let totalUsdValue: BigDecimal = bigDecimal.parseUnits("0") + const productsWithUsdValue = dapp.products.map((product) => { + const productWithUsdValue = computeProductUsdValue( + product, + tokens, + tokenPrices, + ) + totalUsdValue = bigDecimal.add( + totalUsdValue, + bigDecimal.parseUnits(productWithUsdValue.totalUsdValue), + ) + return productWithUsdValue + }) + + // Sort products by totalUsdValue in descending order + const sortedProductsWithUsdValue = productsWithUsdValue + .filter((p) => p.positions.length) + .sort(sortDescendingByUsdValue) + + return { + ...dapp, + products: sortedProductsWithUsdValue, + totalUsdValue: bigDecimal.formatUnits(totalUsdValue), + } + }) + + // Sort dapps by totalUsdValue in descending order + const sortedDappsWithUsdValue = dappsWithUsdValue + .filter((dapp) => dapp.products.length) + .sort(sortDescendingByUsdValue) + + return sortedDappsWithUsdValue +} diff --git a/packages/extension/src/shared/defiDecomposition/helpers/computeDelegatedTokensPositionsUsdValue.test.ts b/packages/extension/src/shared/defiDecomposition/helpers/computeDelegatedTokensPositionsUsdValue.test.ts new file mode 100644 index 000000000..380cc0c17 --- /dev/null +++ b/packages/extension/src/shared/defiDecomposition/helpers/computeDelegatedTokensPositionsUsdValue.test.ts @@ -0,0 +1,82 @@ +import "fake-indexeddb/auto" + +import { getMockAccount } from "../../../../test/account.mock" +import { ArgentDatabase } from "../../idb/db" +import { delegatedTokensPositions } from "../__fixtures__/delegatedTokensPositions" +import { tokenPrices } from "../__fixtures__/tokenPrices" +import { tokens } from "../__fixtures__/tokens" +import type { ParsedDelegatedTokensPosition } from "../schema" +import { + computeDelegatedTokensPositionUsdValue, + computeDelegatedTokensPositionsUsdValue, +} from "./computeDelegatedTokensPositionsUsdValue" +import { parseDelegatedTokensPositions } from "./parseDelegatedTokensPositions" + +describe("computeDelegatedTokensPositionsUsdValue", () => { + const mockAccount = getMockAccount({ + address: "0x123", + networkId: "sepolia-alpha", + }) + + let db: ArgentDatabase + + let parsedPositions: ParsedDelegatedTokensPosition[] = [] + + beforeEach(async () => { + db = new ArgentDatabase() + + parsedPositions = parseDelegatedTokensPositions( + delegatedTokensPositions, + mockAccount, + ) + }) + + afterEach(() => { + db.close() + vi.clearAllMocks() + }) + + describe("computeDelegatedTokensPositionUsdValue", () => { + it("should compute USD value for a delegated tokens position", () => { + const result = computeDelegatedTokensPositionUsdValue( + parsedPositions[0], + tokens, + tokenPrices, + ) + + expect(result?.token.usdValue).toBeDefined() + expect(result?.token.usdValue).not.toBe("0") + expect(result?.token.usdValue).toEqual("1.050005476669488196418579885") + expect(result?.delegatingTo).toBe(parsedPositions[0].delegatingTo) + }) + }) + + describe("computeDelegatedTokensPositionsUsdValue", () => { + it("should compute USD value for all delegated tokens positions", async () => { + const result = computeDelegatedTokensPositionsUsdValue( + parsedPositions, + tokens, + tokenPrices, + ) + expect(result).toBeDefined() + expect(result.totalUsdValue).toBeDefined() + expect(result.totalUsdValue).not.toBe("0") + expect(result.totalUsdValue).toEqual("1.108371443348036103802594379") + expect(result.positions).toHaveLength(parsedPositions.length) + result.positions.forEach((position, index) => { + expect(position.token.usdValue).toBeDefined() + expect(position.token.usdValue).not.toBe("0") + if (index === 0) { + expect(position.token.usdValue).toEqual( + "1.050005476669488196418579885", + ) + } else if (index === 1) { + expect(position.token.usdValue).toEqual( + "0.058365966678547907384014494", + ) + } + expect(position.delegatingTo).toBe(parsedPositions[index].delegatingTo) + }) + }) + }) +}) diff --git a/packages/extension/src/shared/defiDecomposition/helpers/computeDelegatedTokensPositionsUsdValue.ts b/packages/extension/src/shared/defiDecomposition/helpers/computeDelegatedTokensPositionsUsdValue.ts new file mode 100644 index 000000000..eb44671df --- /dev/null +++ b/packages/extension/src/shared/defiDecomposition/helpers/computeDelegatedTokensPositionsUsdValue.ts @@ -0,0 +1,61 @@ +import type { BigDecimal } from "@argent/x-shared" +import { bigDecimal } from "@argent/x-shared" +import type { TokenPriceDetails } from "../../token/__new/types/tokenPrice.model" +import type { + ParsedDelegatedTokensPosition, + ParsedDelegatedTokensPositionWithUsdValue, + ParsedDelegatedTokensPositionsWithUsdValue, +} from "../schema" +import { computeUsdValueForPosition } from "./computeUsdValueForPosition" +import type { Token } from "../../token/__new/types/token.model" +import { sortDescendingByUsdValue } from "./sortDescendingByUsdValue" + +export const computeDelegatedTokensPositionUsdValue = ( + position: ParsedDelegatedTokensPosition, + tokens: Token[], + tokenPrices: TokenPriceDetails[], +): ParsedDelegatedTokensPositionWithUsdValue | undefined => { + const usdValue = computeUsdValueForPosition( + position.token, + tokens, + tokenPrices, + ) + + if (!usdValue) { + return + } + + return { + ...position, + token: { + ...position.token, + usdValue, + }, + } +} + +export const computeDelegatedTokensPositionsUsdValue = ( + positions: ParsedDelegatedTokensPosition[], + tokens: Token[], + tokenPrices: TokenPriceDetails[], +): ParsedDelegatedTokensPositionsWithUsdValue => { + let totalUsdValue: BigDecimal = bigDecimal.parseUnits("0") + const positionsWithUsdValue = positions.map((position) => { + const positionWithUsdValue = computeDelegatedTokensPositionUsdValue( + position, + tokens, + tokenPrices, + ) + totalUsdValue = bigDecimal.add( + totalUsdValue, + bigDecimal.parseUnits(positionWithUsdValue?.token.usdValue || "0"), + ) + return positionWithUsdValue + }) + return { + totalUsdValue: bigDecimal.formatUnits(totalUsdValue), + positions: positionsWithUsdValue + .filter((position) => position !== undefined) + .sort(sortDescendingByUsdValue), + } +} diff --git a/packages/extension/src/shared/defiDecomposition/helpers/computeStakingPositionsUsdValue.test.ts b/packages/extension/src/shared/defiDecomposition/helpers/computeStakingPositionsUsdValue.test.ts new file mode 100644 index 000000000..9db24043a --- /dev/null +++ b/packages/extension/src/shared/defiDecomposition/helpers/computeStakingPositionsUsdValue.test.ts @@ -0,0 +1,73 @@ +import "fake-indexeddb/auto" + +import { getMockAccount } from "../../../../test/account.mock" +import { ArgentDatabase } from "../../idb/db" +import { stakingPositions } from "../__fixtures__/stakingPositions" +import { tokenPrices } from "../__fixtures__/tokenPrices" +import { tokens } from "../__fixtures__/tokens" +import type { ParsedStakingPosition } from "../schema" +import { + computeStakingPositionUsdValue, + computeStakingPositionsUsdValue, +} from "./computeStakingPositionsUsdValue" +import { parseStakingPositions } from "./parseStakingPositions" + +describe("computeStakingPositionsUsdValue", () => { + const mockAccount = getMockAccount({ + address: "0x123", + networkId: "sepolia-alpha", + }) + + let db: ArgentDatabase + + let parsedPositions: ParsedStakingPosition[] = [] + + beforeEach(async () => { + db = new ArgentDatabase() + + parsedPositions = parseStakingPositions(stakingPositions, mockAccount) + }) + + afterEach(() => { + db.close() + vi.clearAllMocks() + }) + + describe("computeStakingPositionUsdValue", () => { + it("should compute USD value for a staking position", async () => { + const result = computeStakingPositionUsdValue( + parsedPositions[0], + tokens, + tokenPrices, + ) + + expect(result).toBeDefined() + expect(result?.token.usdValue).toBeDefined() + expect(result?.token.usdValue).not.toBe("0") + expect(result?.token.usdValue).toEqual("45.665878811978536116099165") + expect(result?.apy).toBe(parsedPositions[0].apy) + }) + }) + + describe("computeStakingPositionsUsdValue", () => { + it("should compute USD value for all staking positions", async () => { + const result = computeStakingPositionsUsdValue( + parsedPositions, + tokens, + tokenPrices, + ) + + expect(result).toBeDefined() + expect(result.totalUsdValue).toBeDefined() + expect(result.totalUsdValue).not.toBe("0") + expect(result.totalUsdValue).toEqual("45.665878811978536116099165") + expect(result.positions).toHaveLength(parsedPositions.length) + result.positions.forEach((position, index) => { + expect(position.token.usdValue).toBeDefined() + expect(position.token.usdValue).not.toBe("0") + expect(position.token.usdValue).toEqual("45.665878811978536116099165") + expect(position.apy).toBe(parsedPositions[index].apy) + }) + }) + }) +}) diff --git a/packages/extension/src/shared/defiDecomposition/helpers/computeStakingPositionsUsdValue.ts b/packages/extension/src/shared/defiDecomposition/helpers/computeStakingPositionsUsdValue.ts new file mode 100644 index 000000000..a260e2831 --- /dev/null +++ b/packages/extension/src/shared/defiDecomposition/helpers/computeStakingPositionsUsdValue.ts @@ -0,0 +1,62 @@ +import type { BigDecimal } from "@argent/x-shared" +import { bigDecimal } from "@argent/x-shared" +import type { TokenPriceDetails } from "../../token/__new/types/tokenPrice.model" +import type { + ParsedStakingPosition, + ParsedStakingPositionWithUsdValue, + ParsedStakingPositionsWithUsdValue, +} from "../schema" +import { computeUsdValueForPosition } from "./computeUsdValueForPosition" +import type { Token } from "../../token/__new/types/token.model" +import { sortDescendingByUsdValue } from "./sortDescendingByUsdValue" + +export const computeStakingPositionUsdValue = ( + position: ParsedStakingPosition, + tokens: Token[], + tokenPrices: TokenPriceDetails[], +): ParsedStakingPositionWithUsdValue | undefined => { + const usdValue = computeUsdValueForPosition( + position.token, + tokens, + tokenPrices, + ) + + if (!usdValue) { + return + } + + return { + ...position, + token: { + ...position.token, + usdValue, + }, + } +} + +export const computeStakingPositionsUsdValue = ( + positions: ParsedStakingPosition[], + tokens: Token[], + tokenPrices: TokenPriceDetails[], +): ParsedStakingPositionsWithUsdValue => { + let totalUsdValue: BigDecimal = bigDecimal.parseUnits("0") + const positionsWithUsdValue = positions.map((position) => { + const positionWithUsdValue = computeStakingPositionUsdValue( + position, + tokens, + tokenPrices, + ) + totalUsdValue = bigDecimal.add( + totalUsdValue, + bigDecimal.parseUnits(positionWithUsdValue?.token.usdValue || "0"), + ) + return positionWithUsdValue + }) + + return { + totalUsdValue: bigDecimal.formatUnits(totalUsdValue), + positions: positionsWithUsdValue + .filter((position) => position !== undefined) + .sort(sortDescendingByUsdValue), + } +} diff --git a/packages/extension/src/shared/defiDecomposition/helpers/computeStrkDelegatedStakingPositionsUsdValue.test.ts b/packages/extension/src/shared/defiDecomposition/helpers/computeStrkDelegatedStakingPositionsUsdValue.test.ts new file mode 100644 index 000000000..39c44e23b --- /dev/null +++ b/packages/extension/src/shared/defiDecomposition/helpers/computeStrkDelegatedStakingPositionsUsdValue.test.ts @@ -0,0 +1,75 @@ +import { getMockAccount } from "../../../../test/account.mock" +import { strkDelegatedStakingPositions } from "../__fixtures__/strkDelegatedStakingPositions" +import { tokenPrices } from "../__fixtures__/tokenPrices" +import { tokens } from "../__fixtures__/tokens" +import type { ParsedStrkDelegatedStakingPosition } from "../schema" +import { + computeStrkDelegatedStakingPositionUsdValue, + computeStrkDelegatedStakingPositionsUsdValue, +} from "./computeStrkDelegatedStakingPositionsUsdValue" +import { parseStrkDelegatedStakingPositions } from "./parseStrkDelegatedStakingPositions" + +describe("computeStrkDelegatedStakingPositionsUsdValue", () => { + const mockAccount = getMockAccount({ + address: "0x123", + networkId: "sepolia-alpha", + }) + + let parsedPositions: ParsedStrkDelegatedStakingPosition[] = [] + + beforeEach(async () => { + parsedPositions = parseStrkDelegatedStakingPositions( + strkDelegatedStakingPositions, + mockAccount, + ) + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + describe("computeStrkDelegatedStakingPositionUsdValue", () => { + it("should compute USD value for a STRK delegated staking position", async () => { + const result = computeStrkDelegatedStakingPositionUsdValue( + parsedPositions[0], + tokens, + tokenPrices, + ) + + expect(result).toBeDefined() + expect(result?.token.usdValue).toBeDefined() + expect(result?.token.usdValue).not.toBe("0") + expect(result?.token.usdValue).toEqual("0.0198765430112345679") + expect(result?.apy).toBe(parsedPositions[0].apy) + expect(result?.stakerInfo).toEqual(parsedPositions[0].stakerInfo) + expect(result?.accruedRewards).toBe(parsedPositions[0].accruedRewards) + }) + }) + + describe("computeStrkDelegatedStakingPositionsUsdValue", () => { + it("should compute USD value for all STRK delegated staking positions", async () => { + const result = computeStrkDelegatedStakingPositionsUsdValue( + parsedPositions, + tokens, + tokenPrices, + ) + expect(result).toBeDefined() + expect(result.totalUsdValue).toBeDefined() + expect(result.totalUsdValue).not.toBe("0") + expect(result.totalUsdValue).toEqual("0.0198765430112345679") + expect(result.positions).toHaveLength(parsedPositions.length) + result.positions.forEach((position, index) => { + expect(position.token.usdValue).toBeDefined() + expect(position.token.usdValue).not.toBe("0") + if (index === 0) { + expect(position.token.usdValue).toEqual("0.0198765430112345679") + } + expect(position.apy).toBe(parsedPositions[index].apy) + expect(position.stakerInfo).toEqual(parsedPositions[index].stakerInfo) + expect(position.accruedRewards).toBe( + parsedPositions[index].accruedRewards, + ) + }) + }) + }) +}) diff --git a/packages/extension/src/shared/defiDecomposition/helpers/computeStrkDelegatedStakingPositionsUsdValue.ts b/packages/extension/src/shared/defiDecomposition/helpers/computeStrkDelegatedStakingPositionsUsdValue.ts new file mode 100644 index 000000000..78a660680 --- /dev/null +++ b/packages/extension/src/shared/defiDecomposition/helpers/computeStrkDelegatedStakingPositionsUsdValue.ts @@ -0,0 +1,62 @@ +import type { BigDecimal } from "@argent/x-shared" +import { bigDecimal } from "@argent/x-shared" +import type { TokenPriceDetails } from "../../token/__new/types/tokenPrice.model" +import type { + ParsedStrkDelegatedStakingPosition, + ParsedStrkDelegatedStakingPositionWithUsdValue, + ParsedStrkDelegatedStakingPositionsWithUsdValue, +} from "../schema" +import type { Token } from "../../token/__new/types/token.model" +import { computeUsdValueForPosition } from "./computeUsdValueForPosition" +import { sortDescendingByUsdValue } from "./sortDescendingByUsdValue" + +export const computeStrkDelegatedStakingPositionUsdValue = ( + position: ParsedStrkDelegatedStakingPosition, + tokens: Token[], + tokenPrices: TokenPriceDetails[], +): ParsedStrkDelegatedStakingPositionWithUsdValue | undefined => { + const usdValue = computeUsdValueForPosition( + position.token, + tokens, + tokenPrices, + ) + + if (!usdValue) { + return + } + + return { + ...position, + token: { + ...position.token, + usdValue, + }, + } +} + +export const computeStrkDelegatedStakingPositionsUsdValue = ( + positions: ParsedStrkDelegatedStakingPosition[], + tokens: Token[], + tokenPrices: TokenPriceDetails[], +): ParsedStrkDelegatedStakingPositionsWithUsdValue => { + let totalUsdValue: BigDecimal = bigDecimal.parseUnits("0") + const positionsWithUsdValue = positions.map((position) => { + const positionWithUsdValue = computeStrkDelegatedStakingPositionUsdValue( + position, + tokens, + tokenPrices, + ) + totalUsdValue = bigDecimal.add( + totalUsdValue, + bigDecimal.parseUnits(positionWithUsdValue?.token.usdValue || "0"), + ) + return positionWithUsdValue + }) + + return { + totalUsdValue: bigDecimal.formatUnits(totalUsdValue), + positions: positionsWithUsdValue + .filter((position) => position !== undefined) + .sort(sortDescendingByUsdValue), + } +} diff --git a/packages/extension/src/shared/defiDecomposition/helpers/computeUsdValueForPosition.ts b/packages/extension/src/shared/defiDecomposition/helpers/computeUsdValueForPosition.ts new file mode 100644 index 000000000..9e0fec304 --- /dev/null +++ b/packages/extension/src/shared/defiDecomposition/helpers/computeUsdValueForPosition.ts @@ -0,0 +1,29 @@ +import { convertTokenAmountToCurrencyValue } from "@argent/x-shared" +import type { Token } from "../../token/__new/types/token.model" +import type { TokenPriceDetails } from "../../token/__new/types/tokenPrice.model" +import { equalToken } from "../../token/__new/utils" +import type { PositionBaseToken } from "../schema" + +export const computeUsdValueForPosition = ( + positionBaseToken: PositionBaseToken, + tokens: Token[], + tokenPrices: TokenPriceDetails[], +) => { + const tokenInfo = tokens.find((t) => equalToken(t, positionBaseToken)) + if (!tokenInfo) { + return + } + + const tokenWithCurrencyValue = tokenPrices.find((token) => + equalToken(token, tokenInfo), + ) + if (!tokenWithCurrencyValue) { + return + } + + return convertTokenAmountToCurrencyValue({ + amount: positionBaseToken.balance, + decimals: tokenInfo.decimals, + unitCurrencyValue: tokenWithCurrencyValue.ccyValue, + }) +} diff --git a/packages/extension/src/shared/defiDecomposition/helpers/getDefiProductName.ts b/packages/extension/src/shared/defiDecomposition/helpers/getDefiProductName.ts new file mode 100644 index 000000000..98e202677 --- /dev/null +++ b/packages/extension/src/shared/defiDecomposition/helpers/getDefiProductName.ts @@ -0,0 +1,12 @@ +const productTypeNameMap: Record = { + concentratedLiquidityPosition: "Providing liquidity", + collateralizedDebtLendingPosition: "Lending", + collateralizedDebtBorrowingPosition: "Borrowing", + delegatedTokens: "Delegated governance", + strkDelegatedStaking: "Native staking", + staking: "Liquid staking", +} + +export const getProductTypeName = (type: string): string => { + return productTypeNameMap[type] || "Unknown" +} diff --git a/packages/extension/src/shared/defiDecomposition/helpers/parseCollateralizedDebtPositions.test.ts b/packages/extension/src/shared/defiDecomposition/helpers/parseCollateralizedDebtPositions.test.ts new file mode 100644 index 000000000..85535c6e0 --- /dev/null +++ b/packages/extension/src/shared/defiDecomposition/helpers/parseCollateralizedDebtPositions.test.ts @@ -0,0 +1,69 @@ +import { getMockAccount } from "../../../../test/account.mock" +import { collateralizedDebtPositions } from "../__fixtures__/collateralizedDebtPositions" +import { concentratedLiquidityPositions } from "../__fixtures__/concentratedLiquidityPositions" +import { + isCollateralizedDebtLendingPosition, + isCollateralizedDebtBorrowingPosition, +} from "../schema" +import { parseCollateralizedDebtPositions } from "./parseCollateralizedDebtPositions" + +describe("parseCollateralizedDebtPositions", () => { + const mockAccount = getMockAccount({ + address: "0x123", + networkId: "sepolia-alpha", + }) + + afterEach(async () => { + vi.clearAllMocks() + }) + + it("should parse collateralized debt positions", async () => { + const result = parseCollateralizedDebtPositions( + collateralizedDebtPositions, + mockAccount, + ) + + expect(result).toBeDefined() + expect(result.lending).toHaveLength(2) // Adjusted to check lending group + expect(result.borrowing).toHaveLength(3) // Adjusted to check borrowing group + + expect(isCollateralizedDebtLendingPosition(result.lending[0])).toBe(true) + expect(isCollateralizedDebtLendingPosition(result.lending[1])).toBe(true) + expect(isCollateralizedDebtBorrowingPosition(result.borrowing[0])).toBe( + true, + ) + expect(isCollateralizedDebtBorrowingPosition(result.borrowing[1])).toBe( + true, + ) + expect(isCollateralizedDebtBorrowingPosition(result.borrowing[2])).toBe( + true, + ) + + if (isCollateralizedDebtLendingPosition(result.lending[0])) { + expect(result.lending[0].token.address).toBe( + "0x027ef4670397069d7d5442cb7945b27338692de0d8896bdb15e6400cf5249f94", + ) + expect(result.lending[0].token.balance).toBe("8000000099") + } + if (isCollateralizedDebtBorrowingPosition(result.borrowing[0])) { + expect(result.borrowing[0].collateralizedPositions).toHaveLength(1) + expect(result.borrowing[0].debtPositions).toHaveLength(1) + expect(result.borrowing[0].collateralizedPositions[0].token.address).toBe( + "0x07809bb63f557736e49ff0ae4a64bd8aa6ea60e3f77f26c520cb92c24e3700d3", + ) + expect(result.borrowing[0].collateralizedPositions[0].token.balance).toBe( + "8000000000008124586153", + ) + } + }) + + it("should return empty arrays for non-collateralized debt positions", async () => { + const result = await parseCollateralizedDebtPositions( + concentratedLiquidityPositions, + mockAccount, + ) + + expect(result.lending).toHaveLength(0) + expect(result.borrowing).toHaveLength(0) + }) +}) diff --git a/packages/extension/src/shared/defiDecomposition/helpers/parseCollateralizedDebtPositions.ts b/packages/extension/src/shared/defiDecomposition/helpers/parseCollateralizedDebtPositions.ts new file mode 100644 index 000000000..ff3f94c4a --- /dev/null +++ b/packages/extension/src/shared/defiDecomposition/helpers/parseCollateralizedDebtPositions.ts @@ -0,0 +1,110 @@ +import type { + Address, + ApiCollateralizedDebtPosition, + ApiDefiDecompositionProduct, +} from "@argent/x-shared" +import { isCollateralizedDebtPosition } from "@argent/x-shared" +import { groupBy } from "lodash-es" +import type { BaseWalletAccount } from "../../wallet.model" +import type { + ParsedCollateralizedDebtBorrowingPosition, + ParsedCollateralizedDebtLendingPosition, + PositionBaseToken, +} from "../schema" + +export const parseCollateralizedDebtPositions = ( + product: ApiDefiDecompositionProduct, + account: BaseWalletAccount, +): { + lending: ParsedCollateralizedDebtLendingPosition[] + borrowing: ParsedCollateralizedDebtBorrowingPosition[] +} => { + if ( + product.type !== "collateralizedDebtPosition" || + product.positions.length === 0 + ) { + return { + lending: [], + borrowing: [], + } + } + + const positions = product.positions as ApiCollateralizedDebtPosition[] + + const parsedPositions: ParsedCollateralizedDebtLendingPosition[] = positions + .map((position) => { + if (!isCollateralizedDebtPosition(position)) { + return + } + + const { id, data, totalBalances, tokenAddress } = position + const { collateral, debt, lending, apy, group, totalApy } = data + + const tokens: PositionBaseToken[] = [] + + Object.entries(totalBalances).map(([address, balance]) => { + tokens.push({ + address: address as Address, + // don't store the negative value + balance: balance.startsWith("-") ? balance.slice(1) : balance, + networkId: account.networkId, + }) + }) + + // only one token is expected + if (tokens.length !== 1) { + return + } + + const liquidityToken = tokenAddress && { + address: tokenAddress, + networkId: account.networkId, + } + + return { + id, + lending, + apy, + totalApy, + group: `${group}`, + collateral, + debt, + token: tokens[0], + liquidityToken, + } + }) + .filter((position) => position !== undefined) + + const lendingPositions = parsedPositions.filter( + (position) => position.lending && !position.debt && !position.collateral, + ) + + // Group positions by their group + const groupedPositions = groupBy(parsedPositions, "group") + + // Process each group + const processedGroups = Object.entries(groupedPositions) + .map(([group, positions]) => { + if (isNaN(Number(group))) { + return + } + const collateralizedPositions = positions.filter((p) => p.collateral) + const debtPositions = positions.filter((p) => p.debt) + + return { + id: positions[0].id, + group, + healthRatio: product.groups?.[group]?.healthRatio, + collateralizedPositions, + debtPositions, + } + }) + .filter( + (x: T): x is NonNullable => x !== undefined && x !== null, // needed or typescript will complain + ) + + return { + lending: lendingPositions, + borrowing: processedGroups, + } +} diff --git a/packages/extension/src/shared/defiDecomposition/helpers/parseConcentratedLiquidityPositions.test.ts b/packages/extension/src/shared/defiDecomposition/helpers/parseConcentratedLiquidityPositions.test.ts new file mode 100644 index 000000000..66024a49e --- /dev/null +++ b/packages/extension/src/shared/defiDecomposition/helpers/parseConcentratedLiquidityPositions.test.ts @@ -0,0 +1,55 @@ +import { isEqualAddress } from "@argent/x-shared" +import { getMockAccount } from "../../../../test/account.mock" +import { collateralizedDebtPositions } from "../__fixtures__/collateralizedDebtPositions" +import { concentratedLiquidityPositions } from "../__fixtures__/concentratedLiquidityPositions" +import { tokens } from "../__fixtures__/tokens" +import { parseConcentratedLiquidityPositions } from "./parseConcentratedLiquidityPositions" + +vi.mock("../../../ui/features/accountTokens/tokens.state", () => ({ + useTokenInfo: ({ address }: { address: string }) => { + return ( + tokens.find((token) => isEqualAddress(token.address, address)) || null + ) + }, +})) + +describe("parseConcentratedLiquidityPositions", async () => { + const mockAccount = getMockAccount({ + address: "0x123", + networkId: "sepolia-alpha", + }) + + afterEach(async () => { + vi.clearAllMocks() + }) + + it("should parse concentrated liquidity positions", async () => { + const result = parseConcentratedLiquidityPositions( + concentratedLiquidityPositions, + mockAccount, + tokens, + ) + + expect(result).toBeDefined() + expect(result).toHaveLength(2) + expect(result[0].poolFeePercentage).toBe("0.05") + expect(result[0].tickSpacingPercentage).toBe("0.1") + expect(result[0].token0.address).toBe( + "0x57181b39020af1416747a7d0d2de6ad5a5b721183136585e8774e1425efd5d2", + ) + expect(result[0].token1.address).toBe( + "0x49d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + ) + expect(result[0].token0.balance).toBe("1128442273967865732") + }) + + it("should return undefined for non-concentrated liquidity positions", async () => { + const result = parseConcentratedLiquidityPositions( + collateralizedDebtPositions, + mockAccount, + tokens, + ) + + expect(result).toHaveLength(0) + }) +}) diff --git a/packages/extension/src/shared/defiDecomposition/helpers/parseConcentratedLiquidityPositions.ts b/packages/extension/src/shared/defiDecomposition/helpers/parseConcentratedLiquidityPositions.ts new file mode 100644 index 000000000..431e2895a --- /dev/null +++ b/packages/extension/src/shared/defiDecomposition/helpers/parseConcentratedLiquidityPositions.ts @@ -0,0 +1,102 @@ +import type { + ApiConcentratedLiquidityPosition, + ApiDefiDecompositionProduct, + ApiDefiDecompositionToken, + Token, +} from "@argent/x-shared" +import { isConcentratedLiquidityPosition } from "@argent/x-shared" +import type { BaseWalletAccount } from "../../wallet.model" +import type { + ParsedConcentratedLiquidityPosition, + ParsedConcentratedLiquidityToken, +} from "../schema" +import { equalToken } from "../../token/__new/utils" + +export const parseConcentratedLiquidityPositions = ( + product: ApiDefiDecompositionProduct, + account: BaseWalletAccount, + tokens: Token[], +): ParsedConcentratedLiquidityPosition[] => { + if ( + product.type !== "concentratedLiquidityPosition" || + product.positions.length === 0 + ) { + return [] + } + + const positions = product.positions as ApiConcentratedLiquidityPosition[] + + return positions + .map((position) => { + if (!isConcentratedLiquidityPosition(position)) { + return + } + + const { id, totalBalances, data, tokenAddress } = position + const { poolFeePercentage, tickSpacingPercentage, token0, token1 } = data + + const parsedToken0 = parseConcentratedLiquidityToken( + token0, + account.networkId, + totalBalances, + tokens, + ) + + const parsedToken1 = parseConcentratedLiquidityToken( + token1, + account.networkId, + totalBalances, + tokens, + ) + + if (!parsedToken0 || !parsedToken1) { + return undefined + } + + const liquidityToken = tokenAddress && { + address: tokenAddress, + networkId: account.networkId, + } + + return { + id, + poolFeePercentage, + tickSpacingPercentage, + token0: parsedToken0, + token1: parsedToken1, + liquidityToken, + } + }) + .filter((position) => position !== undefined) +} + +const parseConcentratedLiquidityToken = ( + token: ApiDefiDecompositionToken, + networkId: string, + totalBalances: ApiConcentratedLiquidityPosition["totalBalances"], + tokens: Token[], +): ParsedConcentratedLiquidityToken | undefined => { + const tokenInfo = tokens.find((t) => + equalToken(t, { + address: token.tokenAddress, + networkId, + }), + ) + + if (!tokenInfo) { + return undefined + } + + const balance = totalBalances[token.tokenAddress] + + return { + address: tokenInfo.address, + networkId: tokenInfo.networkId, + principal: token.principal, + accruedFees: token.accruedFees, + minPrice: token.minPrice, + maxPrice: token.maxPrice, + currentPrice: token.currentPrice, + balance, + } +} diff --git a/packages/extension/src/shared/defiDecomposition/helpers/parseDefiDecomposition.test.ts b/packages/extension/src/shared/defiDecomposition/helpers/parseDefiDecomposition.test.ts new file mode 100644 index 000000000..64dd03b5f --- /dev/null +++ b/packages/extension/src/shared/defiDecomposition/helpers/parseDefiDecomposition.test.ts @@ -0,0 +1,62 @@ +import { getMockAccount } from "../../../../test/account.mock" +import { defiDecomposition } from "../__fixtures__/defiDecomposition" +import { tokens } from "../__fixtures__/tokens" +import { parseDefiDecomposition } from "./parseDefiDecomposition" + +describe("parseDefiDecomposition", () => { + const mockAccount = getMockAccount({ + address: "0x123", + networkId: "sepolia-alpha", + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + it("should parse defi decomposition", async () => { + const result = parseDefiDecomposition( + defiDecomposition, + mockAccount, + tokens, + ) + + expect(result).toBeDefined() + expect(result).toHaveLength(3) + + // Check the first dapp + expect(result[0].dappId).toBe("b513a7c1-eb8a-4201-876c-becd8d445e15") + expect(result[0].products).toHaveLength(1) + expect(result[0].products[0].type).toBe("delegatedTokens") + expect(result[0].products[0].name).toBe("Delegated governance") + + // Check the second dapp + expect(result[1].dappId).toBe("02d43d9d-b82e-44fb-aaa1-69753adc2f14") + expect(result[1].products).toHaveLength(2) + expect(result[1].products[0].type).toBe("collateralizedDebtLendingPosition") + expect(result[1].products[0].name).toBe("Lending") + expect(result[1].products[0].positions).toHaveLength(2) + expect(result[1].products[1].type).toBe( + "collateralizedDebtBorrowingPosition", + ) + expect(result[1].products[1].name).toBe("Borrowing") + expect(result[1].products[1].positions).toHaveLength(3) + + // Check the third dapp + expect(result[2].dappId).toBe("49007844-7a78-4185-be2f-b06bf6fc26a5") + expect(result[2].products).toHaveLength(1) + expect(result[2].products[0].type).toBe("staking") + expect(result[2].products[0].name).toBe("Liquid staking") + }) + + it("should handle empty dapps array", async () => { + const emptyDefiDecomposition = { dapps: [] } + const result = parseDefiDecomposition( + emptyDefiDecomposition, + mockAccount, + tokens, + ) + + expect(result).toBeDefined() + expect(result).toHaveLength(0) + }) +}) diff --git a/packages/extension/src/shared/defiDecomposition/helpers/parseDefiDecomposition.ts b/packages/extension/src/shared/defiDecomposition/helpers/parseDefiDecomposition.ts new file mode 100644 index 000000000..b9282a3ba --- /dev/null +++ b/packages/extension/src/shared/defiDecomposition/helpers/parseDefiDecomposition.ts @@ -0,0 +1,102 @@ +import type { ApiDefiPositions } from "@argent/x-shared" +import type { BaseWalletAccount } from "../../wallet.model" +import type { + ParsedDefiDecomposition, + ParsedPosition, + ParsedProduct, +} from "../schema" +import { parseConcentratedLiquidityPositions } from "./parseConcentratedLiquidityPositions" +import { parseCollateralizedDebtPositions } from "./parseCollateralizedDebtPositions" +import { parseDelegatedTokensPositions } from "./parseDelegatedTokensPositions" +import { parseStrkDelegatedStakingPositions } from "./parseStrkDelegatedStakingPositions" +import { parseStakingPositions } from "./parseStakingPositions" +import type { Token } from "../../token/__new/types/token.model" +import { getProductTypeName } from "./getDefiProductName" + +export const parseDefiDecomposition = ( + apiDefiPositions: ApiDefiPositions, + account: BaseWalletAccount, + tokens: Token[], +): ParsedDefiDecomposition => { + return apiDefiPositions.dapps.map((dapp) => { + const parsedProducts: ParsedProduct[] = dapp.products + .map((product): ParsedProduct[] => { + let positions: ParsedPosition[] = [] + + switch (product.type) { + case "concentratedLiquidityPosition": + positions = parseConcentratedLiquidityPositions( + product, + account, + tokens, + ) + break + case "collateralizedDebtPosition": { + const { lending, borrowing } = parseCollateralizedDebtPositions( + product, + account, + ) + + const baseProduct = { + manageUrl: product.manageUrl, + groups: product.groups, + } + const result = [] + if (lending.length) { + result.push({ + ...baseProduct, + positions: lending, + name: getProductTypeName("collateralizedDebtLendingPosition"), + productId: product.productId + "-lending", + type: "collateralizedDebtLendingPosition", + } as ParsedProduct) + } + + if (borrowing.length) { + result.push({ + ...baseProduct, + positions: borrowing, + name: getProductTypeName("collateralizedDebtBorrowingPosition"), + productId: product.productId + "-borrowing", + type: "collateralizedDebtBorrowingPosition", + } as ParsedProduct) + } + + return result + } + case "delegatedTokens": + positions = parseDelegatedTokensPositions(product, account) + break + case "strkDelegatedStaking": + positions = parseStrkDelegatedStakingPositions(product, account) + break + case "staking": + positions = parseStakingPositions(product, account) + break + default: + positions = [] + } + + if (!positions.length) { + return [] + } + + return [ + { + productId: product.productId, + type: product.type, + manageUrl: product.manageUrl, + name: getProductTypeName(product.type), + positions, + groups: product.groups, + }, + ] + }) + .flat() + + return { + dappId: dapp.dappId, + products: parsedProducts, + } + }) +} diff --git a/packages/extension/src/shared/defiDecomposition/helpers/parseDelegatedTokensPositions.test.ts b/packages/extension/src/shared/defiDecomposition/helpers/parseDelegatedTokensPositions.test.ts new file mode 100644 index 000000000..31f083836 --- /dev/null +++ b/packages/extension/src/shared/defiDecomposition/helpers/parseDelegatedTokensPositions.test.ts @@ -0,0 +1,50 @@ +import { getMockAccount } from "../../../../test/account.mock" +import { delegatedTokensPositions } from "../__fixtures__/delegatedTokensPositions" +import { concentratedLiquidityPositions } from "../__fixtures__/concentratedLiquidityPositions" +import { parseDelegatedTokensPositions } from "./parseDelegatedTokensPositions" + +describe("parseDelegatedTokensPositions", () => { + const mockAccount = getMockAccount({ + address: "0x123", + networkId: "sepolia-alpha", + }) + + beforeEach(() => {}) + + afterEach(async () => { + vi.clearAllMocks() + }) + + it("should parse delegated tokens positions", async () => { + const result = parseDelegatedTokensPositions( + delegatedTokensPositions, + mockAccount, + ) + + expect(result).toBeDefined() + expect(result).toHaveLength(2) + expect(result[0].delegatingTo).toBe( + "0x04d8d7295721a1b972f5b9723f47ac73b1567c8d1e889cdc208840fb07816a54", + ) + expect(result[0].token.address).toBe( + "0x01fad7c03b2ea7fbef306764e20977f8d4eae6191b3a54e4514cc5fc9d19e569", + ) + expect(result[0].token.balance).toBe("528263624904970685") + expect(result[1].delegatingTo).toBe( + "0x064d28d1d1d53a0b5de12e3678699bc9ba32c1cb19ce1c048578581ebb7f8396", + ) + expect(result[1].token.address).toBe( + "0x01fad7c03b2ea7fbef306764e20977f8d4eae6191b3a54e4514cc5fc9d19e569", + ) + expect(result[1].token.balance).toBe("29364244105174014") + }) + + it("should return an empty array for non-delegated tokens positions", async () => { + const result = parseDelegatedTokensPositions( + concentratedLiquidityPositions, + mockAccount, + ) + + expect(result).toHaveLength(0) + }) +}) diff --git a/packages/extension/src/shared/defiDecomposition/helpers/parseDelegatedTokensPositions.ts b/packages/extension/src/shared/defiDecomposition/helpers/parseDelegatedTokensPositions.ts new file mode 100644 index 000000000..c04495a34 --- /dev/null +++ b/packages/extension/src/shared/defiDecomposition/helpers/parseDelegatedTokensPositions.ts @@ -0,0 +1,62 @@ +import type { + Address, + ApiDefiDecompositionProduct, + ApiDelegatedTokens, +} from "@argent/x-shared" +import { isDelegatedTokens } from "@argent/x-shared" +import type { BaseWalletAccount } from "../../wallet.model" +import type { + ParsedDelegatedTokensPosition, + PositionBaseToken, +} from "../schema" + +export const parseDelegatedTokensPositions = ( + product: ApiDefiDecompositionProduct, + account: BaseWalletAccount, +): ParsedDelegatedTokensPosition[] => { + if (product.type !== "delegatedTokens" || product.positions.length === 0) { + return [] + } + + const positions = product.positions as ApiDelegatedTokens[] + + const parsedPositions: ParsedDelegatedTokensPosition[] = positions + .map((position) => { + if (!isDelegatedTokens(position)) { + return + } + + const { id, data, totalBalances, tokenAddress } = position + const { delegatingTo } = data + + const tokens: PositionBaseToken[] = [] + + Object.entries(totalBalances).map(([address, balance]) => { + tokens.push({ + address: address as `0x${string}`, + balance, + networkId: account.networkId, + }) + }) + + // only one token is expected + if (tokens.length !== 1) { + return + } + + const liquidityToken = tokenAddress && { + address: tokenAddress as Address, + networkId: account.networkId, + } + + return { + id, + delegatingTo, + token: tokens[0], + liquidityToken, + } + }) + .filter((position) => position !== undefined) + + return parsedPositions +} diff --git a/packages/extension/src/shared/defiDecomposition/helpers/parseStakingPositions.test.ts b/packages/extension/src/shared/defiDecomposition/helpers/parseStakingPositions.test.ts new file mode 100644 index 000000000..ef3e00a5b --- /dev/null +++ b/packages/extension/src/shared/defiDecomposition/helpers/parseStakingPositions.test.ts @@ -0,0 +1,37 @@ +import { getMockAccount } from "../../../../test/account.mock" +import { stakingPositions } from "../__fixtures__/stakingPositions" +import { concentratedLiquidityPositions } from "../__fixtures__/concentratedLiquidityPositions" + +import { parseStakingPositions } from "./parseStakingPositions" + +describe("parseStakingPositions", () => { + const mockAccount = getMockAccount({ + address: "0x123", + networkId: "sepolia-alpha", + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + it("should parse staking positions", async () => { + const result = parseStakingPositions(stakingPositions, mockAccount) + + expect(result).toBeDefined() + expect(result).toHaveLength(1) + expect(result[0].apy).toBe("0.03192") + expect(result[0].token.address).toBe( + "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + ) + expect(result[0].token.balance).toBe("17431798489347591") + }) + + it("should return an empty array for non-staking positions", async () => { + const result = parseStakingPositions( + concentratedLiquidityPositions, + mockAccount, + ) + + expect(result).toHaveLength(0) + }) +}) diff --git a/packages/extension/src/shared/defiDecomposition/helpers/parseStakingPositions.ts b/packages/extension/src/shared/defiDecomposition/helpers/parseStakingPositions.ts new file mode 100644 index 000000000..f6e861df9 --- /dev/null +++ b/packages/extension/src/shared/defiDecomposition/helpers/parseStakingPositions.ts @@ -0,0 +1,58 @@ +import type { ApiStaking, ApiDefiDecompositionProduct } from "@argent/x-shared" +import { isStaking } from "@argent/x-shared" +import type { BaseWalletAccount } from "../../wallet.model" +import type { ParsedStakingPosition, PositionBaseToken } from "../schema" + +export const parseStakingPositions = ( + product: ApiDefiDecompositionProduct, + account: BaseWalletAccount, +): ParsedStakingPosition[] => { + if (product.type !== "staking" || product.positions.length === 0) { + return [] + } + + const positions = product.positions as ApiStaking[] + + const parsedPositions = positions + .map((position) => { + if (!isStaking(position)) { + return + } + + const { data, totalBalances, tokenAddress } = position + const { apy, totalApy } = data + + const tokens: PositionBaseToken[] = [] + + Object.entries(totalBalances).map(([address, balance]) => { + tokens.push({ + address: address as `0x${string}`, + balance, + networkId: account.networkId, + }) + }) + + // only one token is expected + if (tokens.length !== 1) { + return + } + + const liquidityToken = tokenAddress && { + address: tokenAddress, + networkId: account.networkId, + } + + return { + id: position.id, + apy, + totalApy, + token: tokens[0], + liquidityToken, + } + }) + .filter( + (position): position is ParsedStakingPosition => position !== undefined, + ) + + return parsedPositions +} diff --git a/packages/extension/src/shared/defiDecomposition/helpers/parseStrkDelegatedStakingPositions.test.ts b/packages/extension/src/shared/defiDecomposition/helpers/parseStrkDelegatedStakingPositions.test.ts new file mode 100644 index 000000000..954497836 --- /dev/null +++ b/packages/extension/src/shared/defiDecomposition/helpers/parseStrkDelegatedStakingPositions.test.ts @@ -0,0 +1,47 @@ +import { getMockAccount } from "../../../../test/account.mock" +import { strkDelegatedStakingPositions } from "../__fixtures__/strkDelegatedStakingPositions" +import { concentratedLiquidityPositions } from "../__fixtures__/concentratedLiquidityPositions" + +import { parseStrkDelegatedStakingPositions } from "./parseStrkDelegatedStakingPositions" + +describe("parseStrkDelegatedStakingPositions", () => { + const mockAccount = getMockAccount({ + address: "0x123", + networkId: "sepolia-alpha", + }) + + afterEach(async () => { + vi.clearAllMocks() + }) + + it("should parse STRK delegated staking positions", async () => { + const result = parseStrkDelegatedStakingPositions( + strkDelegatedStakingPositions, + mockAccount, + ) + + expect(result).toBeDefined() + expect(result).toHaveLength(1) + expect(result[0].apy).toBe("0.015014") + expect(result[0].accruedRewards).toBe("0") + expect(result[0].stakerInfo).toEqual({ + name: "Voyager", + iconUrl: "https://www.dappland.com/dapps/voyager/dapp-icon-voyager.png", + address: + "0x7aa2d3f4d79ed1c2183969b353f4f678559ca72a65dae207395a247c968af93", + }) + expect(result[0].token.address).toBe( + "0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d", + ) + expect(result[0].token.balance).toBe("9999999900000000") + }) + + it("should return an empty array for non-STRK delegated staking positions", async () => { + const result = await parseStrkDelegatedStakingPositions( + concentratedLiquidityPositions, + mockAccount, + ) + + expect(result).toHaveLength(0) + }) +}) diff --git a/packages/extension/src/shared/defiDecomposition/helpers/parseStrkDelegatedStakingPositions.ts b/packages/extension/src/shared/defiDecomposition/helpers/parseStrkDelegatedStakingPositions.ts new file mode 100644 index 000000000..00bf69c17 --- /dev/null +++ b/packages/extension/src/shared/defiDecomposition/helpers/parseStrkDelegatedStakingPositions.ts @@ -0,0 +1,72 @@ +import type { + ApiStrkDelegatedStaking, + ApiDefiDecompositionProduct, + Address, +} from "@argent/x-shared" +import { isStrkDelegatedStaking } from "@argent/x-shared" +import type { BaseWalletAccount } from "../../wallet.model" +import type { + ParsedStrkDelegatedStakingPosition, + PositionBaseToken, +} from "../schema" + +export const parseStrkDelegatedStakingPositions = ( + product: ApiDefiDecompositionProduct, + account: BaseWalletAccount, +): ParsedStrkDelegatedStakingPosition[] => { + if ( + product.type !== "strkDelegatedStaking" || + product.positions.length === 0 + ) { + return [] + } + + const positions = product.positions as ApiStrkDelegatedStaking[] + + const parsedPositions: ParsedStrkDelegatedStakingPosition[] = positions + .map((position) => { + if (!isStrkDelegatedStaking(position)) { + return + } + + const { id, data, totalBalances, investmentId } = position + const { + stakerInfo, + accruedRewards, + apy, + totalApy, + pendingWithdrawal, + stakedAmount, + } = data + + const tokens: PositionBaseToken[] = [] + + Object.entries(totalBalances).map(([address, balance]) => { + tokens.push({ + address: address as Address, + balance, + networkId: account.networkId, + }) + }) + + // only one token is expected + if (tokens.length !== 1) { + return + } + + return { + id, + investmentId, + stakerInfo, + accruedRewards, + stakedAmount, + apy, + totalApy, + token: tokens[0], + pendingWithdrawal, + } + }) + .filter((position) => position !== undefined) + + return parsedPositions +} diff --git a/packages/extension/src/shared/defiDecomposition/helpers/sortDescendingByUsdValue.ts b/packages/extension/src/shared/defiDecomposition/helpers/sortDescendingByUsdValue.ts new file mode 100644 index 000000000..ca25609fd --- /dev/null +++ b/packages/extension/src/shared/defiDecomposition/helpers/sortDescendingByUsdValue.ts @@ -0,0 +1,19 @@ +import { bigDecimal } from "@argent/x-shared" + +type SortDescendingByUsdValueParam = + | { totalUsdValue: string } + | { token: { usdValue: string } } + +export const sortDescendingByUsdValue = ( + a: SortDescendingByUsdValueParam, + b: SortDescendingByUsdValueParam, +) => { + const aValue = "totalUsdValue" in a ? a.totalUsdValue : a.token.usdValue + const bValue = "totalUsdValue" in b ? b.totalUsdValue : b.token.usdValue + return bigDecimal.gte( + bigDecimal.parseUnits(bValue), + bigDecimal.parseUnits(aValue), + ) + ? 1 + : -1 +} diff --git a/packages/extension/src/shared/defiDecomposition/index.ts b/packages/extension/src/shared/defiDecomposition/index.ts new file mode 100644 index 000000000..b46f4c480 --- /dev/null +++ b/packages/extension/src/shared/defiDecomposition/index.ts @@ -0,0 +1,5 @@ +import { isFeatureEnabled } from "@argent/x-shared" + +export const isDefiDecompositionEnabled = isFeatureEnabled( + process.env.FEATURE_DEFI_DECOMPOSITION, +) diff --git a/packages/extension/src/shared/defiDecomposition/schema.ts b/packages/extension/src/shared/defiDecomposition/schema.ts new file mode 100644 index 000000000..93e454c98 --- /dev/null +++ b/packages/extension/src/shared/defiDecomposition/schema.ts @@ -0,0 +1,361 @@ +import { productGroupsSchema, stakerInfoSchema } from "@argent/x-shared" +import { z } from "zod" +import { BaseTokenSchema } from "../token/__new/types/token.model" + +const positionBaseTokenSchema = BaseTokenSchema.extend({ + balance: z.string(), +}) + +const positionTokenWithUsdValueSchema = positionBaseTokenSchema.extend({ + usdValue: z.string(), +}) + +export type PositionBaseToken = z.infer +export type PositionTokenWithUsdValue = z.infer< + typeof positionTokenWithUsdValueSchema +> + +const parsedConcentratedLiquidityTokenSchema = positionBaseTokenSchema.extend({ + principal: z.string(), + accruedFees: z.string(), + minPrice: z.string(), + maxPrice: z.string(), + currentPrice: z.string(), +}) + +const parsedConcentratedLiquidityTokenWithUsdValueSchema = + parsedConcentratedLiquidityTokenSchema.extend({ + usdValue: z.string(), + }) + +export const parsedConcentratedLiquidityPositionSchema = z.object({ + id: z.string(), + poolFeePercentage: z.string(), + tickSpacingPercentage: z.string().optional(), + token0: parsedConcentratedLiquidityTokenSchema, + token1: parsedConcentratedLiquidityTokenSchema, + liquidityToken: BaseTokenSchema.optional(), +}) + +export const parsedConcentratedLiquidityPositionWithUsdValueSchema = + parsedConcentratedLiquidityPositionSchema.extend({ + totalUsdValue: z.string(), + token0: parsedConcentratedLiquidityTokenWithUsdValueSchema, + token1: parsedConcentratedLiquidityTokenWithUsdValueSchema, + liquidityToken: BaseTokenSchema.optional(), + }) + +export const parsedConcentratedLiquidityPositionsWithUsdValueSchema = z.object({ + totalUsdValue: z.string(), + positions: z.array(parsedConcentratedLiquidityPositionWithUsdValueSchema), +}) + +export type ParsedConcentratedLiquidityPosition = z.infer< + typeof parsedConcentratedLiquidityPositionSchema +> +export type ParsedConcentratedLiquidityPositionWithUsdValue = z.infer< + typeof parsedConcentratedLiquidityPositionWithUsdValueSchema +> +export type ParsedConcentratedLiquidityToken = z.infer< + typeof parsedConcentratedLiquidityTokenSchema +> +export type ParsedConcentratedLiquidityTokenWithUsdValue = z.infer< + typeof parsedConcentratedLiquidityTokenWithUsdValueSchema +> +export type ParsedConcentratedLiquidityPositionsWithUsdValue = z.infer< + typeof parsedConcentratedLiquidityPositionsWithUsdValueSchema +> + +export const parsedCollateralizedDebtLendingPositionSchema = z.object({ + id: z.string(), + collateral: z.boolean(), + debt: z.boolean(), + lending: z.boolean(), + apy: z.string().optional(), + totalApy: z.string().optional(), + group: z.string().optional(), + token: positionBaseTokenSchema, + liquidityToken: BaseTokenSchema.optional(), +}) + +const parsedCollateralizedDebtLendingPositionWithUsdValueSchema = + parsedCollateralizedDebtLendingPositionSchema.extend({ + token: positionTokenWithUsdValueSchema, + }) + +const parsedCollateralizedDebtBorrowingPositionSchema = z.object({ + id: z.string(), + group: z.string(), + healthRatio: z.string().optional(), + collateralizedPositions: z.array( + parsedCollateralizedDebtLendingPositionSchema, + ), + debtPositions: z.array(parsedCollateralizedDebtLendingPositionSchema), +}) + +export const parsedCollateralizedDebtBorrowingPositionWithUsdValueSchema = + parsedCollateralizedDebtBorrowingPositionSchema.extend({ + totalUsdValue: z.string(), + collateralizedPositions: z.array( + parsedCollateralizedDebtLendingPositionWithUsdValueSchema, + ), + debtPositions: z.array( + parsedCollateralizedDebtLendingPositionWithUsdValueSchema, + ), + collateralizedPositionsTotalUsdValue: z.string(), + debtPositionsTotalUsdValue: z.string(), + }) + +export const parsedCollateralizedDebtPositionSchema = + parsedCollateralizedDebtLendingPositionSchema.or( + parsedCollateralizedDebtBorrowingPositionSchema, + ) + +export const parsedCollateralizedDebtPositionWithUsdValueSchema = + parsedCollateralizedDebtLendingPositionWithUsdValueSchema.or( + parsedCollateralizedDebtBorrowingPositionWithUsdValueSchema, + ) + +export type ParsedCollateralizedDebtLendingPositionWithUsdValueSchema = z.infer< + typeof parsedCollateralizedDebtPositionWithUsdValueSchema +> + +export const parsedCollateralizedDebtPositionsWithUsdValueSchema = z.object({ + totalUsdValue: z.string(), + positions: z.array(parsedCollateralizedDebtPositionWithUsdValueSchema), +}) + +export type ParsedCollateralizedDebtPosition = z.infer< + typeof parsedCollateralizedDebtPositionSchema +> +export type ParsedCollateralizedDebtLendingPosition = z.infer< + typeof parsedCollateralizedDebtLendingPositionSchema +> +export type ParsedCollateralizedDebtPositionWithUsdValue = z.infer< + typeof parsedCollateralizedDebtLendingPositionWithUsdValueSchema +> +export type ParsedCollateralizedDebtBorrowingPosition = z.infer< + typeof parsedCollateralizedDebtBorrowingPositionSchema +> +export type ParsedCollateralizedDebtBorrowingPositionWithUsdValue = z.infer< + typeof parsedCollateralizedDebtBorrowingPositionWithUsdValueSchema +> +export type ParsedCollateralizedDebtPositionsWithUsdValue = z.infer< + typeof parsedCollateralizedDebtPositionsWithUsdValueSchema +> + +const parsedStakingPositionSchema = z.object({ + id: z.string(), + apy: z.string(), + totalApy: z.string().optional(), + token: positionBaseTokenSchema, + liquidityToken: BaseTokenSchema.optional(), +}) + +const parsedStakingPositionWithUsdValueSchema = + parsedStakingPositionSchema.extend({ + token: positionTokenWithUsdValueSchema, + }) + +export const parsedStakingPositionsWithUsdValueSchema = z.object({ + totalUsdValue: z.string(), + positions: z.array(parsedStakingPositionWithUsdValueSchema), +}) + +export type ParsedStakingPosition = z.infer +export type ParsedStakingPositionWithUsdValue = z.infer< + typeof parsedStakingPositionWithUsdValueSchema +> +export type ParsedStakingPositionsWithUsdValue = z.infer< + typeof parsedStakingPositionsWithUsdValueSchema +> + +const parsedStrkDelegatedStakingPositionSchema = z.object({ + id: z.string(), + investmentId: z.string(), + stakerInfo: stakerInfoSchema, + pendingWithdrawal: z + .object({ + amount: z.string(), + withdrawableAfter: z.number(), + }) + .optional(), + accruedRewards: z.string(), + stakedAmount: z.string(), + apy: z.string().optional(), + totalApy: z.string().optional(), + token: positionBaseTokenSchema, +}) + +const parsedStrkDelegatedStakingPositionWithUsdValueSchema = + parsedStrkDelegatedStakingPositionSchema.extend({ + token: positionTokenWithUsdValueSchema, + }) + +export const parsedStrkDelegatedStakingPositionsWithUsdValueSchema = z.object({ + totalUsdValue: z.string(), + positions: z.array(parsedStrkDelegatedStakingPositionWithUsdValueSchema), +}) + +export type ParsedStrkDelegatedStakingPosition = z.infer< + typeof parsedStrkDelegatedStakingPositionSchema +> +export type ParsedStrkDelegatedStakingPositionWithUsdValue = z.infer< + typeof parsedStrkDelegatedStakingPositionWithUsdValueSchema +> +export type ParsedStrkDelegatedStakingPositionsWithUsdValue = z.infer< + typeof parsedStrkDelegatedStakingPositionsWithUsdValueSchema +> + +const parsedDelegatedTokensPositionSchema = z.object({ + id: z.string(), + delegatingTo: z.string(), + token: positionBaseTokenSchema, + liquidityToken: BaseTokenSchema.optional(), +}) + +export const parsedDelegatedTokensPositionWithUsdValueSchema = + parsedDelegatedTokensPositionSchema.extend({ + token: positionTokenWithUsdValueSchema, + }) + +export const parsedDelegatedTokensPositionsWithUsdValueSchema = z.object({ + totalUsdValue: z.string(), + positions: z.array(parsedDelegatedTokensPositionWithUsdValueSchema), +}) + +export type ParsedDelegatedTokensPosition = z.infer< + typeof parsedDelegatedTokensPositionSchema +> +export type ParsedDelegatedTokensPositionWithUsdValue = z.infer< + typeof parsedDelegatedTokensPositionWithUsdValueSchema +> +export type ParsedDelegatedTokensPositionsWithUsdValue = z.infer< + typeof parsedDelegatedTokensPositionsWithUsdValueSchema +> + +export const parsedPositionSchema = parsedConcentratedLiquidityPositionSchema + .or(parsedCollateralizedDebtPositionSchema) + .or(parsedDelegatedTokensPositionSchema) + .or(parsedStrkDelegatedStakingPositionSchema) + .or(parsedStakingPositionSchema) + +export const parsedPositionWithUsdValueSchema = + parsedConcentratedLiquidityPositionWithUsdValueSchema + .or(parsedCollateralizedDebtPositionWithUsdValueSchema) + .or(parsedDelegatedTokensPositionWithUsdValueSchema) + .or(parsedStrkDelegatedStakingPositionWithUsdValueSchema) + .or(parsedStakingPositionWithUsdValueSchema) + +// redeclaring the product type because we split collateralizedDebtPosition into lending and borrowing +export const argentDefiPositionTypeSchema = z.union([ + z.literal("concentratedLiquidityPosition"), + z.literal("collateralizedDebtLendingPosition"), + z.literal("collateralizedDebtBorrowingPosition"), + z.literal("delegatedTokens"), + z.literal("strkDelegatedStaking"), + z.literal("staking"), +]) + +export const parsedProductSchema = z.object({ + type: argentDefiPositionTypeSchema, + productId: z.string().optional(), + manageUrl: z.string().url().optional(), + name: z.string(), + positions: z.array(parsedPositionSchema), + groups: productGroupsSchema.optional(), +}) + +export const parsedProductWithUsdValueSchema = parsedProductSchema.extend({ + positions: z.array(parsedPositionWithUsdValueSchema), + totalUsdValue: z.string(), +}) + +export const parsedDefiDecompositionItemSchema = z.object({ + dappId: z.string(), + products: z.array(parsedProductSchema), +}) + +export const parsedDefiDecompositionSchema = z.array( + parsedDefiDecompositionItemSchema, +) + +export const parsedDefiDecompositionItemWithUsdValueSchema = + parsedDefiDecompositionItemSchema.extend({ + dappId: z.string(), + products: z.array(parsedProductWithUsdValueSchema), + totalUsdValue: z.string(), + }) + +export const parsedDefiDecompositionWithUsdValueSchema = z.array( + parsedDefiDecompositionItemWithUsdValueSchema, +) + +export type ParsedDefiDecompositionItem = z.infer< + typeof parsedDefiDecompositionItemSchema +> + +export type ParsedDefiDecompositionItemWithUsdValue = z.infer< + typeof parsedDefiDecompositionItemWithUsdValueSchema +> + +export type ParsedDefiDecomposition = z.infer< + typeof parsedDefiDecompositionSchema +> + +export type ParsedProduct = z.infer +export type ParsedPosition = z.infer + +export type ParsedPositionWithUsdValue = z.infer< + typeof parsedPositionWithUsdValueSchema +> +export type ParsedProductWithUsdValue = z.infer< + typeof parsedProductWithUsdValueSchema +> +export type ParsedDefiDecompositionWithUsdValue = z.infer< + typeof parsedDefiDecompositionWithUsdValueSchema +> + +export const isCollateralizedDebtLendingPosition = ( + position: unknown, +): position is ParsedCollateralizedDebtLendingPosition => { + return parsedCollateralizedDebtLendingPositionSchema.safeParse(position) + .success +} + +export const isCollateralizedDebtBorrowingPosition = ( + position: unknown, +): position is ParsedCollateralizedDebtBorrowingPosition => { + return parsedCollateralizedDebtBorrowingPositionSchema.safeParse(position) + .success +} + +export const isCollateralizedDebtPosition = ( + position: unknown, +): position is ParsedCollateralizedDebtPosition => { + return parsedCollateralizedDebtPositionSchema.safeParse(position).success +} + +export const isDelegatedTokensPosition = ( + position: unknown, +): position is ParsedDelegatedTokensPosition => { + return parsedDelegatedTokensPositionSchema.safeParse(position).success +} + +export const isStakingPosition = ( + position: unknown, +): position is ParsedStakingPosition => { + return parsedStakingPositionSchema.safeParse(position).success +} + +export const isStrkDelegatedStakingPosition = ( + position: unknown, +): position is ParsedStrkDelegatedStakingPosition => { + return parsedStrkDelegatedStakingPositionSchema.safeParse(position).success +} + +export const isConcentratedLiquidityPosition = ( + position: unknown, +): position is ParsedConcentratedLiquidityPosition => { + return parsedConcentratedLiquidityPositionSchema.safeParse(position).success +} diff --git a/packages/extension/src/shared/dev/store.ts b/packages/extension/src/shared/dev/store.ts new file mode 100644 index 000000000..5e8210c62 --- /dev/null +++ b/packages/extension/src/shared/dev/store.ts @@ -0,0 +1,11 @@ +import { KeyValueStorage } from "../storage" +import type { IDevStorage } from "./types" + +export const devStore = new KeyValueStorage( + { + openInExtendedView: false, + atomsDevToolsEnabled: false, + atomsDebugValueEnabled: false, + }, + "dev:settings", +) diff --git a/packages/extension/src/shared/dev/types.ts b/packages/extension/src/shared/dev/types.ts new file mode 100644 index 000000000..ba050ec37 --- /dev/null +++ b/packages/extension/src/shared/dev/types.ts @@ -0,0 +1,5 @@ +export interface IDevStorage { + openInExtendedView: boolean + atomsDevToolsEnabled: boolean + atomsDebugValueEnabled: boolean +} diff --git a/packages/extension/src/shared/devnet/mintFeeToken.ts b/packages/extension/src/shared/devnet/mintFeeToken.ts index d158e9155..8c3618664 100644 --- a/packages/extension/src/shared/devnet/mintFeeToken.ts +++ b/packages/extension/src/shared/devnet/mintFeeToken.ts @@ -2,9 +2,9 @@ import urlJoin from "url-join" import { networkService as argentNetworkService } from "../network/service" -import { BaseWalletAccount } from "../wallet.model" -import { INetworkService } from "../network/service/INetworkService" -import { FRI, WEI } from "@argent/x-shared" +import type { BaseWalletAccount } from "../wallet.model" +import type { INetworkService } from "../network/service/INetworkService" +import type { FRI, WEI } from "@argent/x-shared" export const tryToMintFeeToken = async ( account: BaseWalletAccount, @@ -29,7 +29,6 @@ export const tryToMintFeeToken = async ( address: account.address.toLowerCase().replace("0x0", "0x"), amount: 1e18, unit, - lite: true, }), }) diff --git a/packages/extension/src/shared/discover/IDiscoverStorage.ts b/packages/extension/src/shared/discover/IDiscoverStorage.ts index a32d28627..5378db577 100644 --- a/packages/extension/src/shared/discover/IDiscoverStorage.ts +++ b/packages/extension/src/shared/discover/IDiscoverStorage.ts @@ -1,4 +1,4 @@ -import { NewsApiReponse } from "./schema" +import type { NewsApiReponse } from "./schema" export type IDiscoverStorage = { data: NewsApiReponse | null diff --git a/packages/extension/src/shared/errors/account.ts b/packages/extension/src/shared/errors/account.ts index 695afcb32..157dfc27c 100644 --- a/packages/extension/src/shared/errors/account.ts +++ b/packages/extension/src/shared/errors/account.ts @@ -1,4 +1,5 @@ -import { BaseError, BaseErrorPayload } from "@argent/x-shared" +import type { BaseErrorPayload } from "@argent/x-shared" +import { BaseError } from "@argent/x-shared" export enum ACCOUNT_ERROR_MESSAGES { NOT_FOUND = "Account not found", @@ -18,6 +19,9 @@ export enum ACCOUNT_ERROR_MESSAGES { UNDEPLOYED_ACCOUNT_CAIRO_VERSION_NOT_FOUND = "Unable to determine cairo version of UNDEPLOYED account", MISSING_METHOD = "Missing method", REFERRAL_NOT_ENABLED = "Referral not enabled", + + UNDEPLOYED_IMPORTED_ACCOUNT = "Imported account cannot be undeployed", + IMPORTED_UPGRADE_NOT_SUPPORTED = "Imported account cannot be upgraded", } export type AccountValidationErrorMessage = keyof typeof ACCOUNT_ERROR_MESSAGES diff --git a/packages/extension/src/shared/errors/accountMessaging.ts b/packages/extension/src/shared/errors/accountMessaging.ts index 1a5090b54..a1472bf45 100644 --- a/packages/extension/src/shared/errors/accountMessaging.ts +++ b/packages/extension/src/shared/errors/accountMessaging.ts @@ -1,4 +1,5 @@ -import { BaseError, BaseErrorPayload } from "@argent/x-shared" +import type { BaseErrorPayload } from "@argent/x-shared" +import { BaseError } from "@argent/x-shared" export enum ACCOUNT_MESSAGING_ERROR_MESSAGES { ESCAPE_CANCELLATION_FAILED = "Escape cancellation failed", diff --git a/packages/extension/src/shared/errors/action.ts b/packages/extension/src/shared/errors/action.ts index 766df9257..f7f56c17c 100644 --- a/packages/extension/src/shared/errors/action.ts +++ b/packages/extension/src/shared/errors/action.ts @@ -1,4 +1,5 @@ -import { BaseError, BaseErrorPayload } from "@argent/x-shared" +import type { BaseErrorPayload } from "@argent/x-shared" +import { BaseError } from "@argent/x-shared" export enum ACTION_ERROR_MESSAGES { NOT_FOUND = "Action not found", diff --git a/packages/extension/src/shared/errors/activity.ts b/packages/extension/src/shared/errors/activity.ts index b0105898c..6bdfa2f62 100644 --- a/packages/extension/src/shared/errors/activity.ts +++ b/packages/extension/src/shared/errors/activity.ts @@ -1,4 +1,5 @@ -import { BaseError, BaseErrorPayload } from "@argent/x-shared" +import type { BaseErrorPayload } from "@argent/x-shared" +import { BaseError } from "@argent/x-shared" export enum ACTIVITY_ERROR_MESSAGES { FETCH_FAILED = "Failed to fetch activities", diff --git a/packages/extension/src/shared/errors/addressBook.ts b/packages/extension/src/shared/errors/addressBook.ts index 6b6afbec4..d04b55fff 100644 --- a/packages/extension/src/shared/errors/addressBook.ts +++ b/packages/extension/src/shared/errors/addressBook.ts @@ -1,4 +1,5 @@ -import { BaseError, BaseErrorPayload } from "@argent/x-shared" +import type { BaseErrorPayload } from "@argent/x-shared" +import { BaseError } from "@argent/x-shared" export enum ADDRESS_BOOK_ERROR_MESSAGES { ADD_CONTACT_FAILED = "Could not add contact", diff --git a/packages/extension/src/shared/errors/argentAccount.ts b/packages/extension/src/shared/errors/argentAccount.ts index 261d7342f..b05c9cebe 100644 --- a/packages/extension/src/shared/errors/argentAccount.ts +++ b/packages/extension/src/shared/errors/argentAccount.ts @@ -1,4 +1,5 @@ -import { BaseError, BaseErrorPayload } from "@argent/x-shared" +import type { BaseErrorPayload } from "@argent/x-shared" +import { BaseError } from "@argent/x-shared" /** * localAccounts <-- all local accounts diff --git a/packages/extension/src/shared/errors/errorData.ts b/packages/extension/src/shared/errors/errorData.ts index 3af6ef6e9..709e759e6 100644 --- a/packages/extension/src/shared/errors/errorData.ts +++ b/packages/extension/src/shared/errors/errorData.ts @@ -1,4 +1,4 @@ -import { JsonValue } from "@argent/x-shared" +import type { JsonValue } from "@argent/x-shared" export interface ErrorData { code?: T diff --git a/packages/extension/src/shared/errors/ledger.ts b/packages/extension/src/shared/errors/ledger.ts index 101aaf047..6d5b8c4f4 100644 --- a/packages/extension/src/shared/errors/ledger.ts +++ b/packages/extension/src/shared/errors/ledger.ts @@ -1,5 +1,6 @@ import { LedgerError } from "@ledgerhq/hw-app-starknet" -import { BaseError, BaseErrorPayload } from "@argent/x-shared" +import type { BaseErrorPayload } from "@argent/x-shared" +import { BaseError } from "@argent/x-shared" import { isNumber, isString } from "lodash-es" // Taken from ledger clientjs @@ -18,6 +19,7 @@ export enum AX_LEDGER_ERROR_MESSAGES { PUBKEY_GENERATION_ERROR = "Public keys generation error", UNKNOWN_ERROR = "Unknown error", UNSUPPORTED_ACCOUNT_TYPE = "Unsupported account type", + UNSUPPORTED_APP_VERSION = "To transact, you must update the starknet app on ledger to the latest version", } const STATUS_CODE_TO_AX_LEDGER_ERROR_MESSAGE = { diff --git a/packages/extension/src/shared/errors/multisig.ts b/packages/extension/src/shared/errors/multisig.ts index e532af472..f1c966a91 100644 --- a/packages/extension/src/shared/errors/multisig.ts +++ b/packages/extension/src/shared/errors/multisig.ts @@ -1,4 +1,5 @@ -import { BaseError, BaseErrorPayload } from "@argent/x-shared" +import type { BaseErrorPayload } from "@argent/x-shared" +import { BaseError } from "@argent/x-shared" export enum MULTISIG_ERROR_MESSAGE { PENDING_MULTISIG_TRANSACTION_NOT_FOUND = "Pending Multisig transaction not found", diff --git a/packages/extension/src/shared/errors/network.ts b/packages/extension/src/shared/errors/network.ts index 77d883eef..90a920acc 100644 --- a/packages/extension/src/shared/errors/network.ts +++ b/packages/extension/src/shared/errors/network.ts @@ -1,4 +1,5 @@ -import { BaseError, BaseErrorPayload } from "@argent/x-shared" +import type { BaseErrorPayload } from "@argent/x-shared" +import { BaseError } from "@argent/x-shared" export enum NETWORK_ERROR_MESSAGES { NOT_FOUND = "Network not found", diff --git a/packages/extension/src/shared/errors/pubKey.ts b/packages/extension/src/shared/errors/pubKey.ts index f182d34f6..a7741c159 100644 --- a/packages/extension/src/shared/errors/pubKey.ts +++ b/packages/extension/src/shared/errors/pubKey.ts @@ -1,4 +1,5 @@ -import { BaseError, BaseErrorPayload } from "@argent/x-shared" +import type { BaseErrorPayload } from "@argent/x-shared" +import { BaseError } from "@argent/x-shared" export enum PUB_KEY_ERROR_MESSAGES { FAILED_NEXT_PUB_KEY_GENERATION = "Failed to generate next PubKey", diff --git a/packages/extension/src/shared/errors/recovery.ts b/packages/extension/src/shared/errors/recovery.ts index 25bcc3e19..e03b8ce08 100644 --- a/packages/extension/src/shared/errors/recovery.ts +++ b/packages/extension/src/shared/errors/recovery.ts @@ -1,4 +1,5 @@ -import { BaseError, BaseErrorPayload } from "@argent/x-shared" +import type { BaseErrorPayload } from "@argent/x-shared" +import { BaseError } from "@argent/x-shared" export enum RECOVERY_ERROR_MESSAGE { ARGENT_ACCOUNT_DISCOVERY_URL_NOT_SET = "ARGENT_ACCOUNT_DISCOVERY_URL is not set", diff --git a/packages/extension/src/shared/errors/review.ts b/packages/extension/src/shared/errors/review.ts index f69526f2d..7a1e54434 100644 --- a/packages/extension/src/shared/errors/review.ts +++ b/packages/extension/src/shared/errors/review.ts @@ -1,9 +1,11 @@ -import { BaseError, BaseErrorPayload } from "@argent/x-shared" +import type { BaseErrorPayload } from "@argent/x-shared" +import { BaseError } from "@argent/x-shared" export enum REVIEW_ERROR_MESSAGE { SIMULATE_AND_REVIEW_FAILED = "Something went wrong fetching review", NO_CALLS_FOUND = "No calls found", ONCHAIN_FEE_ESTIMATION_FAILED = "Failed to estimate fees onchain", + INVALID_TRANSACTION_ACTION = "Invalid transaction action", } export type ReviewErrorMessage = keyof typeof REVIEW_ERROR_MESSAGE diff --git a/packages/extension/src/shared/errors/riskAssessment.ts b/packages/extension/src/shared/errors/riskAssessment.ts index bac1b7cf0..0d5c96c25 100644 --- a/packages/extension/src/shared/errors/riskAssessment.ts +++ b/packages/extension/src/shared/errors/riskAssessment.ts @@ -1,4 +1,5 @@ -import { BaseError, BaseErrorPayload } from "@argent/x-shared" +import type { BaseErrorPayload } from "@argent/x-shared" +import { BaseError } from "@argent/x-shared" export enum RISK_ASSESSMENT_ERROR_MESSAGE { ERROR_FETCHING = "Encountered an error while fetching risk assessment", diff --git a/packages/extension/src/shared/errors/session.ts b/packages/extension/src/shared/errors/session.ts index e3c27b25f..3dd0ef969 100644 --- a/packages/extension/src/shared/errors/session.ts +++ b/packages/extension/src/shared/errors/session.ts @@ -1,4 +1,5 @@ -import { BaseError, BaseErrorPayload } from "@argent/x-shared" +import type { BaseErrorPayload } from "@argent/x-shared" +import { BaseError } from "@argent/x-shared" export enum SESSION_ERROR_MESSAGES { NO_OPEN_SESSION = "There is no open session", diff --git a/packages/extension/src/shared/errors/swap.ts b/packages/extension/src/shared/errors/swap.ts index 4993976d4..2537f6a71 100644 --- a/packages/extension/src/shared/errors/swap.ts +++ b/packages/extension/src/shared/errors/swap.ts @@ -1,4 +1,5 @@ -import { BaseError, BaseErrorPayload } from "@argent/x-shared" +import type { BaseErrorPayload } from "@argent/x-shared" +import { BaseError } from "@argent/x-shared" export enum SWAP_ERROR_MESSAGE { NO_SWAP_URL = "Swap base url not provided", diff --git a/packages/extension/src/shared/errors/token.ts b/packages/extension/src/shared/errors/token.ts index fe753c948..ef091b670 100644 --- a/packages/extension/src/shared/errors/token.ts +++ b/packages/extension/src/shared/errors/token.ts @@ -1,8 +1,10 @@ -import { BaseError, BaseErrorPayload } from "@argent/x-shared" +import type { BaseErrorPayload } from "@argent/x-shared" +import { BaseError } from "@argent/x-shared" export enum TOKEN_ERROR_MESSAGES { NO_TOKEN_API_URL = "ARGENT_API_TOKENS_URL is not defined", NO_TOKEN_PRICE_API_URL = "ARGENT_API_TOKENS_PRICES_URL is not defined", + NO_TOKEN_REPORT_SPAM_API_URL = "ARGENT_API_TOKENS_REPORT_SPAM_URL is not defined", TOKEN_PARSING_ERROR = "Unable to parse token data response", TOKEN_PRICE_PARSING_ERROR = "Unable to parse token price response", TOKEN_PRICE_NOT_FOUND = "Token price not found", diff --git a/packages/extension/src/shared/errors/transaction.ts b/packages/extension/src/shared/errors/transaction.ts index 0f8f76b41..95eb42f27 100644 --- a/packages/extension/src/shared/errors/transaction.ts +++ b/packages/extension/src/shared/errors/transaction.ts @@ -1,4 +1,5 @@ -import { BaseError, BaseErrorPayload } from "@argent/x-shared" +import type { BaseErrorPayload } from "@argent/x-shared" +import { BaseError } from "@argent/x-shared" export enum TRANSACTION_ERROR_MESSAGE { NO_TRANSACTION_HASH = "Transaction hash is not available", diff --git a/packages/extension/src/shared/errors/udc.ts b/packages/extension/src/shared/errors/udc.ts index 32b7d68d4..bbd00d21d 100644 --- a/packages/extension/src/shared/errors/udc.ts +++ b/packages/extension/src/shared/errors/udc.ts @@ -1,4 +1,5 @@ -import { BaseError, BaseErrorPayload } from "@argent/x-shared" +import type { BaseErrorPayload } from "@argent/x-shared" +import { BaseError } from "@argent/x-shared" export enum UDC_ERROR_MESSAGES { FETCH_CONTRACT_CONTRUCTOR_PARAMS = "Error while fetching contract constructor params", diff --git a/packages/extension/src/shared/errors/wallet.ts b/packages/extension/src/shared/errors/wallet.ts index 169f3473b..95bda5ba5 100644 --- a/packages/extension/src/shared/errors/wallet.ts +++ b/packages/extension/src/shared/errors/wallet.ts @@ -1,4 +1,5 @@ -import { BaseError, BaseErrorPayload } from "@argent/x-shared" +import type { BaseErrorPayload } from "@argent/x-shared" +import { BaseError } from "@argent/x-shared" export enum WALLET_ERROR_MESSAGES { ALREADY_INITIALIZED = "Wallet already initialized", NOT_INITIALIZED = "Wallet not initialized", diff --git a/packages/extension/src/shared/explorer/type.ts b/packages/extension/src/shared/explorer/type.ts index 495c0b5ae..545d3415e 100644 --- a/packages/extension/src/shared/explorer/type.ts +++ b/packages/extension/src/shared/explorer/type.ts @@ -1,4 +1,4 @@ -import { ExecutionStatus, ExtendedFinalityStatus } from "../transactions" +import type { ExecutionStatus, ExtendedFinalityStatus } from "../transactions" import { z } from "zod" export interface IExplorerTransactionParameters { diff --git a/packages/extension/src/shared/extensionMessenger.ts b/packages/extension/src/shared/extensionMessenger.ts index 455fa4de1..218ac00c9 100644 --- a/packages/extension/src/shared/extensionMessenger.ts +++ b/packages/extension/src/shared/extensionMessenger.ts @@ -1,5 +1,5 @@ -import { Listener, Message, Messenger } from "../messages" -import browser from "webextension-polyfill" +import type { Listener, Message, Messenger } from "../messages" +import type browser from "webextension-polyfill" export class ExtensionMessenger implements Messenger { private listeners = new Map< diff --git a/packages/extension/src/shared/feeToken/repository/preference.ts b/packages/extension/src/shared/feeToken/repository/preference.ts index c9b979fd1..1b076d874 100644 --- a/packages/extension/src/shared/feeToken/repository/preference.ts +++ b/packages/extension/src/shared/feeToken/repository/preference.ts @@ -1,7 +1,7 @@ import { STRK_TOKEN_ADDRESS } from "../../network/constants" import { KeyValueStorage } from "../../storage" import { adaptKeyValue } from "../../storage/__new/keyvalue" -import { FeeTokenPreference } from "../types/preference.model" +import type { FeeTokenPreference } from "../types/preference.model" export const feeTokenPreferenceKeyValueStore = new KeyValueStorage( diff --git a/packages/extension/src/shared/feeToken/service/FeeTokenService.test.ts b/packages/extension/src/shared/feeToken/service/FeeTokenService.test.ts index ca58eb6da..1f1dd31a7 100644 --- a/packages/extension/src/shared/feeToken/service/FeeTokenService.test.ts +++ b/packages/extension/src/shared/feeToken/service/FeeTokenService.test.ts @@ -1,9 +1,8 @@ import "fake-indexeddb/auto" -import { Mocked } from "vitest" +import type { Mocked } from "vitest" +import type { Address, IHttpService } from "@argent/x-shared" import { - Address, - IHttpService, TXV3_ACCOUNT_CLASS_HASH, ETH_TOKEN_ADDRESS, STRK_TOKEN_ADDRESS, @@ -17,25 +16,36 @@ import { getMockTokenWithBalance, } from "../../../../test/token.mock" import { AccountService } from "../../account/service/accountService/AccountService" -import { IAccountService } from "../../account/service/accountService/IAccountService" +import type { IAccountService } from "../../account/service/accountService/IAccountService" import { StarknetChainService } from "../../chain/service/StarknetChainService" import { ArgentDatabase } from "../../idb/db" -import { INetworkService } from "../../network/service/INetworkService" +import type { INetworkService } from "../../network/service/INetworkService" import { NetworkService } from "../../network/service/NetworkService" -import { INetworkRepo } from "../../network/store" +import type { INetworkRepo } from "../../network/store" import { MockFnObjectStore, MockFnRepository, } from "../../storage/__new/__test__/mockFunctionImplementation" import { TokenService } from "../../token/__new/service/TokenService" -import { ITransactionsRepository } from "../../transactions/store" -import { FeeTokenPreference } from "../types/preference.model" +import type { ITransactionsRepository } from "../../transactions/store" +import type { FeeTokenPreference } from "../types/preference.model" import { FeeTokenService } from "./FeeTokenService" +import { getMockSigner } from "../../../../test/account.mock" +import { getAccountIdentifier } from "../../utils/accountIdentifier" +import type { IPKManager } from "../../accountImport/pkManager/IPKManager" +import { emitterMock } from "../../test.utils" const randomAddress1 = addressSchema.parse(stark.randomAddress()) +const randomId = getAccountIdentifier( + randomAddress1, + "sepolia-alpha", + getMockSigner(), +) + const BASE_INFO_ENDPOINT = "https://token.info.argent47.net/v1" const BASE_PRICES_ENDPOINT = "https://token.prices.argent47.net/v1" +const BASE_TOKENS_REPORT_SPAM = "https://token.report-spam.argent47.net/v1" describe("FeeTokenService", () => { let tokenService: TokenService @@ -47,6 +57,8 @@ describe("FeeTokenService", () => { let mockFeeTokenPreferenceStore: MockFnObjectStore let db: ArgentDatabase + let mockPkManager: Mocked + beforeEach(() => { mockNetworkRepo = new MockFnRepository() mockTransactionsRepo = new MockFnRepository() @@ -56,8 +68,18 @@ describe("FeeTokenService", () => { ) const mockAccountRepo = new MockFnRepository() const chainService = new StarknetChainService(mockNetworkService) + mockPkManager = { + storeEncryptedKey: vi.fn(), + retrieveDecryptedKey: vi.fn(), + removeKey: vi.fn(), + } as Mocked mockAccountService = vi.mocked( - new AccountService(chainService, mockAccountRepo), + new AccountService( + emitterMock, + chainService, + mockAccountRepo, + mockPkManager, + ), ) mockFeeTokenPreferenceStore = new MockFnObjectStore() mockFeeTokenPreferenceStore.get = vi.fn().mockResolvedValue({ @@ -76,6 +98,7 @@ describe("FeeTokenService", () => { mockHttpService, BASE_INFO_ENDPOINT, BASE_PRICES_ENDPOINT, + BASE_TOKENS_REPORT_SPAM, ) feeTokenService = new FeeTokenService( @@ -96,6 +119,7 @@ describe("FeeTokenService", () => { classHash: TXV3_ACCOUNT_CLASS_HASH as Address, address: randomAddress1, networkId: mockNetwork.id, + id: randomId, } const mockBaseTokens = [ getMockBaseToken({ networkId: mockNetwork.id }), @@ -174,6 +198,7 @@ describe("FeeTokenService", () => { ...token, account: { ...token.account, + id: randomId, address: stripAddressZeroPadding(token?.account?.address || ""), }, address: stripAddressZeroPadding(token.address), @@ -187,6 +212,7 @@ describe("FeeTokenService", () => { classHash: TXV3_ACCOUNT_CLASS_HASH as Address, address: randomAddress1, networkId: mockNetwork.id, + id: randomId, } const mockBaseTokens = [ getMockBaseToken({ networkId: mockNetwork.id }), @@ -251,6 +277,7 @@ describe("FeeTokenService", () => { ...token, account: { ...token.account, + id: randomId, address: stripAddressZeroPadding(token?.account?.address || ""), }, address: stripAddressZeroPadding(token.address), @@ -264,6 +291,7 @@ describe("FeeTokenService", () => { classHash: "0x123" as Address, address: randomAddress1, networkId: mockNetwork.id, + id: randomId, } const mockBaseTokens = [ getMockBaseToken({ networkId: mockNetwork.id }), @@ -311,6 +339,7 @@ describe("FeeTokenService", () => { expect(result).toEqual({ ...mockTokens[0], account: { + id: randomId, address: stripAddressZeroPadding(mockAccount.address), networkId: mockTokens[0].networkId, }, @@ -325,6 +354,7 @@ describe("FeeTokenService", () => { classHash: TXV3_ACCOUNT_CLASS_HASH as Address, address: randomAddress1, networkId: mockNetwork.id, + id: randomId, } const mockBaseTokens = [ getMockBaseToken({ networkId: mockNetwork.id }), @@ -377,6 +407,7 @@ describe("FeeTokenService", () => { balance: mockTokensWithBalance[1].balance, address: stripAddressZeroPadding(mockTokens[1].address), account: { + id: randomId, address: stripAddressZeroPadding(mockAccount.address), networkId: mockTokens[1].networkId, }, diff --git a/packages/extension/src/shared/feeToken/service/FeeTokenService.ts b/packages/extension/src/shared/feeToken/service/FeeTokenService.ts index 2ce02db25..973e28686 100644 --- a/packages/extension/src/shared/feeToken/service/FeeTokenService.ts +++ b/packages/extension/src/shared/feeToken/service/FeeTokenService.ts @@ -1,20 +1,20 @@ +import type { Address } from "@argent/x-shared" import { - Address, classHashSupportsTxV3, feeTokenNeedsTxV3Support, } from "@argent/x-shared" -import { IAccountService } from "../../account/service/accountService/IAccountService" -import { INetworkService } from "../../network/service/INetworkService" -import { IObjectStore } from "../../storage/__new/interface" -import { ITokenService } from "../../token/__new/service/ITokenService" -import { TokenWithBalance } from "../../token/__new/types/tokenBalance.model" +import type { IAccountService } from "../../account/service/accountService/IAccountService" +import type { INetworkService } from "../../network/service/INetworkService" +import type { IObjectStore } from "../../storage/__new/interface" +import type { ITokenService } from "../../token/__new/service/ITokenService" +import type { TokenWithBalance } from "../../token/__new/types/tokenBalance.model" import { equalToken } from "../../token/__new/utils" import { accountsEqual } from "../../utils/accountsEqual" -import { BaseWalletAccount, WalletAccount } from "../../wallet.model" +import type { BaseWalletAccount, WalletAccount } from "../../wallet.model" import { FEE_TOKEN_PREFERENCE_BY_SYMBOL } from "../constants" -import { FeeTokenPreference } from "../types/preference.model" +import type { FeeTokenPreference } from "../types/preference.model" import { pickBestFeeToken } from "../utils" -import { IFeeTokenService } from "./IFeeTokenService" +import type { IFeeTokenService } from "./IFeeTokenService" export class FeeTokenService implements IFeeTokenService { constructor( @@ -46,10 +46,11 @@ export class FeeTokenService implements IFeeTokenService { } return true }) - const feeTokenBalances = await this.tokenService.getTokenBalancesForAccount( - account, - accountFeeTokens, - ) + const feeTokenBalances = + await this.tokenService.getAllTokenBalancesForAccount( + account, + accountFeeTokens, + ) const feeTokensWithBalances: TokenWithBalance[] = accountFeeTokens.map( (token) => { const tokenBalance = feeTokenBalances.find((tb) => @@ -61,6 +62,7 @@ export class FeeTokenService implements IFeeTokenService { ...token, ...tokenBalance, account: { + id: account.id, address: tokenBalance.account, networkId: tokenBalance.networkId, }, @@ -71,7 +73,11 @@ export class FeeTokenService implements IFeeTokenService { ...token, ...{ balance: "0", - account: { address: account.address, networkId: account.networkId }, + account: { + id: account.id, + address: account.address, + networkId: account.networkId, + }, }, } }, diff --git a/packages/extension/src/shared/feeToken/service/IFeeTokenService.ts b/packages/extension/src/shared/feeToken/service/IFeeTokenService.ts index b4424e238..98744abac 100644 --- a/packages/extension/src/shared/feeToken/service/IFeeTokenService.ts +++ b/packages/extension/src/shared/feeToken/service/IFeeTokenService.ts @@ -1,6 +1,6 @@ -import { TokenWithBalance } from "../../token/__new/types/tokenBalance.model" -import { BaseWalletAccount, WalletAccount } from "../../wallet.model" -import { FeeTokenPreference } from "../types/preference.model" +import type { TokenWithBalance } from "../../token/__new/types/tokenBalance.model" +import type { BaseWalletAccount, WalletAccount } from "../../wallet.model" +import type { FeeTokenPreference } from "../types/preference.model" export interface IFeeTokenService { getFeeTokens( diff --git a/packages/extension/src/shared/feeToken/utils.test.ts b/packages/extension/src/shared/feeToken/utils.test.ts index 414aff58f..61ae4d38c 100644 --- a/packages/extension/src/shared/feeToken/utils.test.ts +++ b/packages/extension/src/shared/feeToken/utils.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from "vitest" -import { Address } from "@argent/x-shared" -import { BaseTokenWithBalance } from "../token/__new/types/tokenBalance.model" +import type { Address } from "@argent/x-shared" +import type { BaseTokenWithBalance } from "../token/__new/types/tokenBalance.model" import { STRK_TOKEN_ADDRESS } from "../network/constants" import { pickBestFeeToken } from "./utils" diff --git a/packages/extension/src/shared/feeToken/utils.ts b/packages/extension/src/shared/feeToken/utils.ts index e72b12d74..6bfcc21fe 100644 --- a/packages/extension/src/shared/feeToken/utils.ts +++ b/packages/extension/src/shared/feeToken/utils.ts @@ -1,5 +1,6 @@ -import { Address, isEqualAddress } from "@argent/x-shared" -import { FeeTokenPreferenceOption } from "./types/preference.model" +import type { Address } from "@argent/x-shared" +import { isEqualAddress } from "@argent/x-shared" +import type { FeeTokenPreferenceOption } from "./types/preference.model" import { num } from "starknet" import { arrayOrderWith } from "../utils/arrayOrderWith" import { FEE_TOKEN_PREFERENCE_BY_ADDRESS } from "./constants" diff --git a/packages/extension/src/shared/idb/argentDb.ts b/packages/extension/src/shared/idb/argentDb.ts new file mode 100644 index 000000000..95171c504 --- /dev/null +++ b/packages/extension/src/shared/idb/argentDb.ts @@ -0,0 +1,3 @@ +import { ArgentDatabase } from "./db" + +export const argentDb = new ArgentDatabase() diff --git a/packages/extension/src/shared/idb/db.test.ts b/packages/extension/src/shared/idb/db.test.ts index b34f0b613..4ed36cfda 100644 --- a/packages/extension/src/shared/idb/db.test.ts +++ b/packages/extension/src/shared/idb/db.test.ts @@ -1,7 +1,8 @@ import "fake-indexeddb/auto" import { describe, it, expect, beforeEach, afterEach, vi } from "vitest" import { ArgentDatabase } from "./db" // Adjust the import path as needed -import { Token } from "../token/__new/types/token.model" +import type { Token } from "../token/__new/types/token.model" +import type { Hex } from "@argent/x-shared" const testTokens: Token[] = [ { @@ -73,6 +74,29 @@ const testTokens2: Token[] = [ }, ] +const testTokenPrices = [ + { + pricingId: 1, + ethValue: "1", + ccyValue: "2611.09", + ethDayChange: "0", + ccyDayChange: "-0.021263", + networkId: "sepholia-alpha", + address: + "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7" as Hex, + }, + { + pricingId: 1, + ethValue: "1", + ccyValue: "2611.09", + ethDayChange: "0", + ccyDayChange: "-0.021263", + networkId: "sepholia-alpha", + address: + "0x057146f6409deb4c9fa12866915dd952aa07c1eb2752e451d7f3b042086bdeb8" as Hex, + }, +] + describe("ArgentDatabase", () => { let db: ArgentDatabase @@ -93,7 +117,10 @@ describe("ArgentDatabase", () => { await db.tokens.add(record) await db.tokens.put({ ...record, name: "Updated name" }) + await db.tokenPrices.add(testTokenPrices[0]) + const tokens = await db.tokens.toArray() + const tokenPrices = await db.tokenPrices.toArray() expect(tokens.length).toBe(1) expect(tokens[0]).toEqual({ @@ -102,19 +129,33 @@ describe("ArgentDatabase", () => { "0x3cea9c0644433dab0c431d63a8e0e51741f93afaafc3b1d81e193ed928945c1", name: "Updated name", }) + + expect(tokenPrices.length).toBe(1) + expect(tokenPrices[0]).toEqual({ + ...testTokenPrices[0], + address: + "0x49d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + }) }) it("should sanitize addresses on put", async () => { const record = testTokens[0] await db.tokens.put(record) + await db.tokenPrices.put(testTokenPrices[0]) const tokens = await db.tokens.toArray() + const tokenPrices = await db.tokenPrices.toArray() expect(tokens[0]).toEqual({ ...record, address: "0x3cea9c0644433dab0c431d63a8e0e51741f93afaafc3b1d81e193ed928945c1", }) + expect(tokenPrices[0]).toEqual({ + ...testTokenPrices[0], + address: + "0x49d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + }) }) it("should sanitize addresses on put, and update existing record", async () => { @@ -133,7 +174,11 @@ describe("ArgentDatabase", () => { it("should sanitize addresses on bulkAdd", async () => { await db.tokens.bulkAdd(testTokens2) + await db.tokenPrices.clear() + await db.tokenPrices.bulkAdd(testTokenPrices) + const tokens = await db.tokens.toArray() + const tokenPrices = await db.tokenPrices.toArray() expect(tokens).toEqual([ { @@ -147,13 +192,28 @@ describe("ArgentDatabase", () => { "0x3cea9c0644433dab0c431d63a8e0e51741f93afaafc3b1d81e193ed928945c4", }, ]) + + expect(tokenPrices).toEqual([ + { + ...testTokenPrices[0], + address: + "0x49d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + }, + { + ...testTokenPrices[1], + address: + "0x57146f6409deb4c9fa12866915dd952aa07c1eb2752e451d7f3b042086bdeb8", + }, + ]) }) it("should sanitize addresses on bulkPut", async () => { await db.tokens.bulkPut(testTokens2) - const tokens = await db.tokens.toArray() + await db.tokenPrices.bulkPut(testTokenPrices) + const tokenPrices = await db.tokenPrices.toArray() + expect(tokens).toEqual([ { ...testTokens2[0], @@ -166,5 +226,92 @@ describe("ArgentDatabase", () => { "0x3cea9c0644433dab0c431d63a8e0e51741f93afaafc3b1d81e193ed928945c4", }, ]) + + expect(tokenPrices).toEqual([ + { + ...testTokenPrices[0], + address: + "0x49d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + }, + { + ...testTokenPrices[1], + address: + "0x57146f6409deb4c9fa12866915dd952aa07c1eb2752e451d7f3b042086bdeb8", + }, + ]) + }) + + it("should hide scam tokens on add", async () => { + const record = testTokens[0] + record.tags = ["scam"] + await db.tokens.add(record) + + const tokens = await db.tokens.toArray() + + expect(tokens.length).toBe(1) + expect(tokens[0]).toEqual({ + ...record, + hidden: true, + }) + }) + + it("should hide scam tokens on put", async () => { + const record = testTokens[0] + record.tags = ["scam"] + await db.tokens.put(record) + + const tokens = await db.tokens.toArray() + + expect(tokens[0]).toEqual({ + ...record, + hidden: true, + }) + }) + + it("should hide scam tokens on bulkAdd", async () => { + testTokens[0].tags = ["scam"] + await db.tokens.bulkAdd(testTokens) + + const tokens = await db.tokens.toArray() + + expect(tokens).toEqual([ + { + ...testTokens[0], + address: + "0x3cea9c0644433dab0c431d63a8e0e51741f93afaafc3b1d81e193ed928945c1", + hidden: true, + }, + { + ...testTokens[1], + address: + "0x3cea9c0644433dab0c431d63a8e0e51741f93afaafc3b1d81e193ed928945c2", + }, + ]) + }) + + it("should allow unhide for scam token", async () => { + testTokens[0].tags = ["scam"] + testTokens[1].tags = ["scam"] + await db.tokens.bulkAdd(testTokens) + + testTokens[0].hidden = false + await db.tokens.put(testTokens[0]) + + const tokens = await db.tokens.toArray() + + expect(tokens).toEqual([ + { + ...testTokens[0], + address: + "0x3cea9c0644433dab0c431d63a8e0e51741f93afaafc3b1d81e193ed928945c1", + hidden: false, + }, + { + ...testTokens[1], + address: + "0x3cea9c0644433dab0c431d63a8e0e51741f93afaafc3b1d81e193ed928945c2", + hidden: true, + }, + ]) }) }) diff --git a/packages/extension/src/shared/idb/db.ts b/packages/extension/src/shared/idb/db.ts index b847d8797..7e3463753 100644 --- a/packages/extension/src/shared/idb/db.ts +++ b/packages/extension/src/shared/idb/db.ts @@ -1,19 +1,28 @@ -import { Dexie, Table } from "dexie" +import type { Table } from "dexie" +import { Dexie } from "dexie" import type { Token } from "../token/__new/types/token.model" import type { BaseTokenWithBalance } from "../token/__new/types/tokenBalance.model" -import { DbTokensInfoResponse } from "../token/__new/types/tokenInfo.model" -import { TokenPriceDetails } from "../token/__new/types/tokenPrice.model" -import { DexieSchema, StorageSchema } from "./schema" +import type { DbTokensInfoResponse } from "../token/__new/types/tokenInfo.model" +import type { TokenPriceDetails } from "../token/__new/types/tokenPrice.model" +import type { DexieSchema } from "./schema" +import { StorageSchema } from "./schema" import { noop } from "lodash-es" import { equalToken, parsedDefaultTokens } from "../token/__new/utils" import { mergeTokens } from "../token/__new/repository/mergeTokens" import logger from "dexie-logger" import { isFeatureEnabled } from "@argent/x-shared" -import addressNormalizerMiddleware from "./addressNormalizerMiddleware" +import addressNormalizerMiddleware from "./middleware/addressNormalizerMiddleware" +import hideSpamTokensMiddleware from "./middleware/hideSpamTokensMiddleware" +import type { + AccountInvestmentsKey, + AccountInvestments, +} from "../investments/types" interface ArgentDatabaseConfig { + name?: string version?: number skipAddressNormalizer?: boolean + skipHideScamTokens?: boolean } export class ArgentDatabase extends Dexie { @@ -21,10 +30,12 @@ export class ArgentDatabase extends Dexie { tokenBalances: Table tokensInfo: Table tokenPrices: Table + + investments!: Table _config?: ArgentDatabaseConfig constructor(config?: ArgentDatabaseConfig) { - super("Argent") + super(config?.name ?? "Argent") this._config = config this.initialiseDatabase() @@ -33,6 +44,12 @@ export class ArgentDatabase extends Dexie { this.tokensInfo = this.table(StorageSchema.OBJECT_STORE.TOKENS_INFO) this.tokenPrices = this.table(StorageSchema.OBJECT_STORE.TOKEN_PRICES) + if ( + this.tables.some((t) => t.name === StorageSchema.OBJECT_STORE.INVESTMENTS) + ) { + this.investments = this.table(StorageSchema.OBJECT_STORE.INVESTMENTS) + } + this.registerHooks() } @@ -71,6 +88,10 @@ export class ArgentDatabase extends Dexie { if (!this._config?.skipAddressNormalizer) { this.use(addressNormalizerMiddleware()) } + + if (!this._config?.skipHideScamTokens) { + this.use(hideSpamTokensMiddleware()) + } } public async clear() { @@ -78,5 +99,3 @@ export class ArgentDatabase extends Dexie { await this.open() } } - -export const argentDb = new ArgentDatabase() diff --git a/packages/extension/src/shared/idb/addressNormalizerMiddleware.ts b/packages/extension/src/shared/idb/middleware/addressNormalizerMiddleware.ts similarity index 92% rename from packages/extension/src/shared/idb/addressNormalizerMiddleware.ts rename to packages/extension/src/shared/idb/middleware/addressNormalizerMiddleware.ts index 92c275dfe..b42cedb67 100644 --- a/packages/extension/src/shared/idb/addressNormalizerMiddleware.ts +++ b/packages/extension/src/shared/idb/middleware/addressNormalizerMiddleware.ts @@ -1,11 +1,12 @@ -import { +import type { DBCore, DBCoreAddRequest, DBCoreMutateRequest, DBCorePutRequest, Middleware, } from "dexie" -import { Address, stripAddressZeroPadding } from "@argent/x-shared" +import type { Address } from "@argent/x-shared" +import { stripAddressZeroPadding } from "@argent/x-shared" const normalizeAddress = (address: string): Address => { return stripAddressZeroPadding(address) as Address diff --git a/packages/extension/src/shared/idb/middleware/hideSpamTokensMiddleware.ts b/packages/extension/src/shared/idb/middleware/hideSpamTokensMiddleware.ts new file mode 100644 index 000000000..c91508844 --- /dev/null +++ b/packages/extension/src/shared/idb/middleware/hideSpamTokensMiddleware.ts @@ -0,0 +1,75 @@ +import { stripAddressZeroPadding } from "@argent/x-shared" +import type { + DBCore, + DBCoreAddRequest, + DBCoreMutateRequest, + DBCorePutRequest, + DBCoreTable, + Middleware, +} from "dexie" +import type { Token } from "../../token/__new/types/token.model" + +export const processRecord = (record: Token, existingRecord?: Token) => { + // Check for "scam" in tags and set hidden flag only if it did not have the tag before + if ( + record.tags?.includes("scam") && + !existingRecord?.tags?.includes("scam") + ) { + record.hidden = true + } + + return record +} + +const processRecords = async ( + request: DBCoreAddRequest | DBCorePutRequest, + table: DBCoreTable, +) => { + const records = request.values as Token[] + + return Promise.all( + records.map(async (record) => { + let existingRecord + if (request.type === "add") { + existingRecord = undefined + } else { + existingRecord = await table.get({ + trans: request.trans, + key: [stripAddressZeroPadding(record.address), record.networkId], + }) + } + return processRecord(record, existingRecord) + }), + ) +} + +const hideSpamTokensMiddleware = (): Middleware => { + return { + stack: "dbcore", + name: "hideSpamTokens", + create: (downlevelDatabase: DBCore) => ({ + ...downlevelDatabase, + table: (tableName: string) => { + const downlevelTable = downlevelDatabase.table(tableName) + return { + ...downlevelTable, + mutate: async (req: DBCoreMutateRequest) => { + const { type } = req + let updatedReq = req + if (type === "add" || type === "put") { + if (tableName === "tokens") { + updatedReq = req as DBCoreAddRequest | DBCorePutRequest + updatedReq.values = await processRecords(req, downlevelTable) + } + } + return downlevelTable.mutate(updatedReq).then((res) => { + return res + }) + }, + } + }, + }), + } +} + +export default hideSpamTokensMiddleware diff --git a/packages/extension/src/shared/idb/migration.test.ts b/packages/extension/src/shared/idb/migration.test.ts index 50f8258a6..96374dc3b 100644 --- a/packages/extension/src/shared/idb/migration.test.ts +++ b/packages/extension/src/shared/idb/migration.test.ts @@ -1,7 +1,18 @@ import "fake-indexeddb/auto" -import { describe, it, afterEach, vi } from "vitest" + +import type { Hex } from "@argent/x-shared" +import { afterEach, describe, it, vi } from "vitest" +import type { Token } from "../token/__new/types/token.model" import { ArgentDatabase } from "./db" // Adjust the import path as needed -import { Hex } from "@argent/x-shared" +import type { DbTokensInfoResponse } from "../token/__new/types/tokenInfo.model" +import { equalToken } from "../token/__new/utils" +import { tokensInfo } from "../defiDecomposition/__fixtures__/tokensInfo" +import { tokenPrices } from "../defiDecomposition/__fixtures__/tokenPrices" +import { tokens } from "../defiDecomposition/__fixtures__/tokens" +import { parseDefiDecomposition } from "../defiDecomposition/helpers/parseDefiDecomposition" +import { defiDecomposition } from "../defiDecomposition/__fixtures__/defiDecomposition" +import { getMockAccount } from "../../../test/account.mock" +import type { AccountInvestments } from "../investments/types" const mockTokensWithBalance = [ { @@ -22,18 +33,171 @@ const mockTokensWithBalance = [ }, ] +const mockTokens: Token[] = [ + { + address: + "0x3cea9c0644433dab0c431d63a8e0e51741f93afaafc3b1d81e193ed928945c1", + networkId: "1", + name: "Token One", + symbol: "ONE", + decimals: 18, + id: 1, + iconUrl: "https://example.com/icon1.png", + showAlways: true, + popular: true, + custom: false, + pricingId: 101, + tradable: true, + tags: ["DeFi", "Utility"], + }, + { + address: + "0x3cea9c0644433dab0c431d63a8e0e51741f93afaafc3b1d81e193ed928945c2", + networkId: "1", + name: "Token Two", + symbol: "TWO", + decimals: 8, + id: 2, + iconUrl: "https://example.com/icon2.png", + showAlways: false, + popular: false, + custom: true, + pricingId: 102, + tradable: false, + tags: ["Governance", "scam"], + }, +] + +const mockTokenPrices = [ + { + pricingId: 1, + ethValue: "1", + ccyValue: "2611.09", + ethDayChange: "0", + ccyDayChange: "-0.021263", + networkId: "sepholia-alpha", + address: + "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7" as Hex, + }, + { + pricingId: 1, + ethValue: "1", + ccyValue: "2611.09", + ethDayChange: "0", + ccyDayChange: "-0.021263", + networkId: "sepholia-alpha", + address: + "0x057146f6409deb4c9fa12866915dd952aa07c1eb2752e451d7f3b042086bdeb8" as Hex, + }, + { + pricingId: 1, + ethValue: "1", + ccyValue: "2551.657296", + ethDayChange: "0", + ccyDayChange: "0.022293", + networkId: "sepholia-alpha", + address: + "0x49d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7" as Hex, + }, + { + pricingId: 1, + ethValue: "1", + ccyValue: "2551.657296", + ethDayChange: "0", + ccyDayChange: "0.022293", + networkId: "sepholia-alpha", + address: + "0x57146f6409deb4c9fa12866915dd952aa07c1eb2752e451d7f3b042086bdeb8" as Hex, + }, +] + +const mockTokenInfo: DbTokensInfoResponse[] = [ + { + id: 5, + address: + "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7" as Hex, + name: "Ethereum", + symbol: "ETH", + decimals: 18, + sendable: true, + popular: true, + refundable: false, + listed: true, + tradable: true, + category: "tokens", + pricingId: 4154, + networkId: "sepholia-alpha", + updatedAt: 1724097597360, + }, + { + id: 5, + address: + "0x49d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7" as Hex, + name: "Ethereum", + symbol: "ETH", + decimals: 18, + sendable: true, + popular: true, + refundable: false, + listed: true, + tradable: true, + category: "tokens", + pricingId: 4154, + networkId: "sepholia-alpha", + updatedAt: 1724097597360, + }, + { + id: 15, + address: + "0x057146f6409deb4c9fa12866915dd952aa07c1eb2752e451d7f3b042086bdeb8" as Hex, + category: "tokens", + decimals: 18, + listed: true, + name: "Nostra ETH Interest Collat.", + popular: false, + pricingId: 1, + refundable: false, + sendable: true, + symbol: "iETH-c", + tradable: false, + networkId: "sepholia-alpha", + updatedAt: 1724097597360, + }, + { + id: 15, + address: + "0x57146f6409deb4c9fa12866915dd952aa07c1eb2752e451d7f3b042086bdeb8" as Hex, + category: "tokens", + decimals: 18, + listed: true, + name: "Nostra ETH Interest Collat.", + popular: false, + pricingId: 1, + refundable: false, + sendable: true, + symbol: "iETH-c", + tradable: false, + networkId: "sepholia-alpha", + updatedAt: 1724097597360, + }, +] + describe("migrate ArgentDatabase", () => { afterEach(() => { vi.clearAllMocks() }) it("from version 2 to version 3", async () => { - let db = new ArgentDatabase({ version: 2, skipAddressNormalizer: true }) + let db = new ArgentDatabase({ + version: 2, + skipAddressNormalizer: true, + skipHideScamTokens: true, + }) await db.tokenBalances.bulkAdd(mockTokensWithBalance) - await db.close() - db = new ArgentDatabase() + db.close() + db = new ArgentDatabase({ version: 3 }) await db.open() const tokenBalances = await db.tokenBalances.toArray() @@ -55,4 +219,177 @@ describe("migrate ArgentDatabase", () => { networkId: "sepolia-alpha", }) }) + + it("from version 3 to version 4", async () => { + let db = new ArgentDatabase({ + version: 3, + }) + await db.tokens.bulkAdd(mockTokens) + + await db.close() + db = new ArgentDatabase({ version: 4 }) + await db.open() + + const tokens = await db.tokens.toArray() + const filteredTokens = tokens.filter( + (t) => equalToken(t, mockTokens[0]) || equalToken(t, mockTokens[1]), + ) + + expect(filteredTokens).toEqual([ + { + ...mockTokens[0], + }, + { + ...mockTokens[1], + hidden: true, + }, + ]) + }) + + it("from version 4 to version 5", async () => { + let db = new ArgentDatabase({ version: 4, skipAddressNormalizer: true }) + + await db.tokenPrices.bulkAdd(mockTokenPrices) + await db.tokensInfo.bulkAdd(mockTokenInfo) + + await db.close() + db = new ArgentDatabase({ version: 5 }) + await db.open() + + const tokenPrices = await db.tokenPrices.toArray() + + expect(tokenPrices.length).toBe(2) + expect(tokenPrices[0]).toEqual({ + pricingId: 1, + ethValue: "1", + ccyValue: "2611.09", + ethDayChange: "0", + ccyDayChange: "-0.021263", + networkId: "sepholia-alpha", + address: + "0x49d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + }) + expect(tokenPrices[1]).toEqual({ + pricingId: 1, + ethValue: "1", + ccyValue: "2611.09", + ethDayChange: "0", + ccyDayChange: "-0.021263", + networkId: "sepholia-alpha", + address: + "0x57146f6409deb4c9fa12866915dd952aa07c1eb2752e451d7f3b042086bdeb8", + }) + + const tokensInfo = await db.tokensInfo.toArray() + + expect(tokensInfo.length).toBe(2) + expect(tokensInfo[0]).toEqual({ + id: 5, + address: + "0x49d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + name: "Ethereum", + symbol: "ETH", + decimals: 18, + sendable: true, + popular: true, + refundable: false, + listed: true, + tradable: true, + category: "tokens", + pricingId: 4154, + networkId: "sepholia-alpha", + updatedAt: 1724097597360, + }) + expect(tokensInfo[1]).toEqual({ + id: 15, + address: + "0x57146f6409deb4c9fa12866915dd952aa07c1eb2752e451d7f3b042086bdeb8", + category: "tokens", + decimals: 18, + listed: true, + name: "Nostra ETH Interest Collat.", + popular: false, + pricingId: 1, + refundable: false, + sendable: true, + symbol: "iETH-c", + tradable: false, + networkId: "sepholia-alpha", + updatedAt: 1724097597360, + }) + }) + + it("from version 5 to version 6", async () => { + let db = new ArgentDatabase({ version: 5 }) + + const strkOnLocalHost = { + id: 2, + address: + "0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d", + name: "Starknet", + symbol: "STRK", + decimals: 18, + network: "localhost", + networkId: "localhost", + iconUrl: "https://dv3jj1unlp2jl.cloudfront.net/128/color/strk.png", + showAlways: true, + } + + db.close() + db = new ArgentDatabase({ version: 6 }) + await db.open() + + const tokens = await db.tokens.toArray() + + expect(tokens).toContainEqual({ + address: + "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + networkId: "sepolia-alpha", + network: "sepolia-alpha", + iconUrl: "https://dv3jj1unlp2jl.cloudfront.net/128/color/eth.png", + name: "Ethereum", + symbol: "ETH", + decimals: 18, + id: 1, + showAlways: true, + }) + + expect(tokens).toContainEqual(strkOnLocalHost) + }) + + it("from version 6 to version 7", async () => { + let db = new ArgentDatabase({ version: 6, skipAddressNormalizer: true }) + + await db.tokensInfo.bulkPut(tokensInfo) + await db.tokenPrices.bulkPut(tokenPrices) + await db.tokens.bulkPut(tokens) + + db.close() + db = new ArgentDatabase({ version: 7, skipAddressNormalizer: true }) + await db.open() + + const mockAccount = getMockAccount({ + address: "0x123", + networkId: "sepolia-alpha", + }) + + const dbTokensInfo = await db.tokensInfo.toArray() + + const result = parseDefiDecomposition( + defiDecomposition, + mockAccount, + dbTokensInfo, + ) + + const mockInvestments: AccountInvestments = { + address: "0x123", + networkId: "sepolia-alpha", + defiDecomposition: result, + } + await db.investments.add(mockInvestments) + + const investments = await db.investments.toArray() + + expect(investments[0]).toEqual(mockInvestments) + }) }) diff --git a/packages/extension/src/shared/idb/schema.ts b/packages/extension/src/shared/idb/schema.ts index 3ba3e29ef..6bf80cf41 100644 --- a/packages/extension/src/shared/idb/schema.ts +++ b/packages/extension/src/shared/idb/schema.ts @@ -1,45 +1,11 @@ +import { stripAddressZeroPadding } from "@argent/x-shared" import type { Dexie, Transaction } from "dexie" +import type { Token } from "../token/__new/types/token.model" +import type { BaseTokenWithBalance } from "../token/__new/types/tokenBalance.model" import { parsedDefaultTokens } from "../token/__new/utils" -import { stripAddressZeroPadding } from "@argent/x-shared" -import { BaseTokenWithBalance } from "../token/__new/types/tokenBalance.model" -import { sanitizeRecord } from "./addressNormalizerMiddleware" +import { deduplicateTable } from "./utils/deduplicateTable" +import { mergeTokensWithDefaults } from "../token/__new/repository/mergeTokens" -export const deduplicateTable = async ( - table: Dexie.Table, - getPrimaryKey: (item: T) => string, -) => { - const allItems = await table.toArray() - const uniqueItems = new Map() - - allItems.forEach((item) => { - const primaryKey = getPrimaryKey(item) - const existingItem = uniqueItems.get(primaryKey) - if ( - !existingItem || - (item.updatedAt && item.updatedAt > existingItem.updatedAt) - ) { - uniqueItems.set(primaryKey, item) - } - }) - // Clear the table - await table.clear() - // Add back the unique items one by one - for (const item of uniqueItems.values()) { - try { - await table.add(sanitizeRecord(item)) - } catch (error) { - console.error(`Failed to add item: ${JSON.stringify(item)}`, error) - try { - await table.update(getPrimaryKey(item), item) - } catch (updateError) { - console.error( - `Failed to update item: ${JSON.stringify(item)}`, - updateError, - ) - } - } - } -} export interface DexieSchema { schema: Record upgrade?: (transaction: Transaction, database?: Dexie) => Promise @@ -54,9 +20,13 @@ export class StorageSchema { TOKEN_BALANCES: "token_balances", TOKENS_INFO: "tokens_info", TOKEN_PRICES: "token_prices", + + INVESTMENTS: "investments", } as const } + // DEV NOTE: Don’t declare all columns like in SQL. You only declare properties you want to index, that is properties you want to use in a where(…) query. + // (See https://dexie.org/docs/API-Reference#quick-reference) static get schema(): DexieSchema[] { return [ { @@ -127,6 +97,73 @@ export class StorageSchema { }, version: 3, }, + { + schema: { + [StorageSchema.OBJECT_STORE.TOKENS]: + "[address+networkId], id, iconUrl, showAlways, popular, custom, pricingId, tradable, address, networkId, name, symbol, decimals, *tags, hidden", + }, + upgrade: async (transaction: Transaction) => { + const table = transaction.table(StorageSchema.OBJECT_STORE.TOKENS) + const tokensList: Token[] = await table.toArray() + const updatedTokens = tokensList + .filter((token) => token.tags?.includes("scam")) + .map((token) => ({ + ...token, + hidden: true, + })) + + await table.bulkPut(updatedTokens) + }, + version: 4, + }, + { + schema: { + [StorageSchema.OBJECT_STORE.TOKEN_PRICES]: + "[address+networkId], address, networkId, pricingId, ethValue, ccyValue, ethDayChange, ccyDayChange", + }, + upgrade: async (transaction: Transaction) => { + const tokenPrices = transaction.table( + StorageSchema.OBJECT_STORE.TOKEN_PRICES, + ) + await deduplicateTable( + tokenPrices, + (item: BaseTokenWithBalance) => + `${stripAddressZeroPadding(item.address)}-${item.networkId}`, + ) + + const tokensInfo = transaction.table( + StorageSchema.OBJECT_STORE.TOKENS_INFO, + ) + await deduplicateTable( + tokensInfo, + (item: BaseTokenWithBalance) => + `${stripAddressZeroPadding(item.address)}-${item.networkId}`, + ) + }, + version: 5, + }, + { + schema: { + [StorageSchema.OBJECT_STORE.TOKENS]: + "[address+networkId], id, iconUrl, showAlways, popular, custom, pricingId, tradable, address, networkId, name, symbol, decimals, *tags, hidden", + }, + upgrade: async (transaction: Transaction) => { + const tokens = transaction.table(StorageSchema.OBJECT_STORE.TOKENS) + + const mergedWithNewDefaultTokens = mergeTokensWithDefaults( + await tokens.toArray(), + ) + + await tokens.bulkPut(mergedWithNewDefaultTokens) + }, + version: 6, + }, + { + schema: { + [StorageSchema.OBJECT_STORE.INVESTMENTS]: "[address+networkId]", + }, + version: 7, + }, ] } } diff --git a/packages/extension/src/shared/idb/chunkedBulkPut.ts b/packages/extension/src/shared/idb/utils/chunkedBulkPut.ts similarity index 100% rename from packages/extension/src/shared/idb/chunkedBulkPut.ts rename to packages/extension/src/shared/idb/utils/chunkedBulkPut.ts diff --git a/packages/extension/src/shared/idb/utils/deduplicateTable.ts b/packages/extension/src/shared/idb/utils/deduplicateTable.ts new file mode 100644 index 000000000..2a75d4c16 --- /dev/null +++ b/packages/extension/src/shared/idb/utils/deduplicateTable.ts @@ -0,0 +1,61 @@ +import type Dexie from "dexie" +import { browserExtensionSentryWithScope } from "../../sentry/scope" +import { sanitizeRecord } from "../middleware/addressNormalizerMiddleware" + +export const deduplicateTable = async ( + table: Dexie.Table, + getPrimaryKey: (item: T) => string, +) => { + const allItems = await table.toArray() + const uniqueItems = new Map() + + // Remove all duplicates and sanitize records + allItems.forEach((item) => { + const primaryKey = getPrimaryKey(item) + const existingItem = uniqueItems.get(primaryKey) + if ( + !existingItem || + (item.updatedAt && item.updatedAt > existingItem.updatedAt) + ) { + uniqueItems.set(primaryKey, sanitizeRecord(item)) + } + }) + + // Clear the table + await table.clear() + + try { + // Insert the sanitized records + const sanitizedRecords = Array.from(uniqueItems.values()) + await table.bulkAdd(sanitizedRecords) + } catch (error) { + console.error( + `Failed to bulk add sanitized items while deduplicating table: ${table.name}`, + error, + ) + browserExtensionSentryWithScope((scope) => { + scope.setLevel("warning") + scope.captureException( + new Error( + `Failed to bulk add sanitized items while deduplicating table: ${table.name}: ${error}`, + ), + ) + }) + try { + await table.bulkAdd(allItems) + } catch (error) { + console.error( + `Failed to restore exiting items in ${table.name} in deduplication process`, + error, + ) + browserExtensionSentryWithScope((scope) => { + scope.setLevel("error") + scope.captureException( + new Error( + `Failed to restore exiting items in ${table.name} in deduplication process: ${error}`, + ), + ) + }) + } + } +} diff --git a/packages/extension/src/shared/investments/IInvestmentService.ts b/packages/extension/src/shared/investments/IInvestmentService.ts new file mode 100644 index 000000000..bece1cbb8 --- /dev/null +++ b/packages/extension/src/shared/investments/IInvestmentService.ts @@ -0,0 +1,11 @@ +import type { + Investment, + StrkDelegatedStakingInvestment, +} from "@argent/x-shared" + +export interface IInvestmentService { + getAllInvestments(): Promise + getStrkDelegatedStakingInvestments(): Promise< + StrkDelegatedStakingInvestment[] + > +} diff --git a/packages/extension/src/shared/investments/types.ts b/packages/extension/src/shared/investments/types.ts new file mode 100644 index 000000000..04e74e527 --- /dev/null +++ b/packages/extension/src/shared/investments/types.ts @@ -0,0 +1,10 @@ +import type { Address } from "@argent/x-shared" +import type { ParsedDefiDecomposition } from "../defiDecomposition/schema" + +export type AccountInvestments = { + address: Address + networkId: string + defiDecomposition: ParsedDefiDecomposition +} + +export type AccountInvestmentsKey = [Address, string] diff --git a/packages/extension/src/shared/knownDapps.ts b/packages/extension/src/shared/knownDapps.ts index eaf1f456a..e69de29bb 100644 --- a/packages/extension/src/shared/knownDapps.ts +++ b/packages/extension/src/shared/knownDapps.ts @@ -1,61 +0,0 @@ -import { includesAddress } from "@argent/x-shared" - -import untypedKnownDapps from "../assets/known-dapps.json" - -export interface KnownDapp { - /** a unique internal id for this dapp e.g. mydapp-example-xyz */ - id: string - /** the dapp hostnames e.g. mydapp.example.xyz */ - hosts: string[] - /** the display title */ - title: string - /** default icon if one cannot be retrieved automatically by {@link getDappDisplayAttributes} */ - icon?: string - /** known contract addresses per network */ - contracts: { - [network: string]: string[] - } - /** dapp url on dappland */ - dappland?: string -} - -const knownDapps: KnownDapp[] = untypedKnownDapps - -export { knownDapps } - -export const isKnownDappForContractAddress = ( - address: string, - network?: string, -) => { - return !!getKnownDappForContractAddress(address, network) -} - -export const getKnownDappForContractAddress = ( - address?: string, - network?: string, -) => { - if (!address) { - return - } - try { - const knownContract = knownDapps.find(({ contracts }) => { - if (network) { - return includesAddress(address, contracts[network] ?? []) - } - return includesAddress(address, Object.values(contracts).flat()) - }) - if (knownContract) { - return knownContract - } - } catch (e) { - // ignore parsing error - } -} - -export const getKnownDappForHost = (host: string) => { - return knownDapps.find((knownDapp) => knownDapp.hosts.includes(host)) -} - -export const getKnownDappForId = (id: string) => { - return knownDapps.find((knownDapp) => id === knownDapp.id) -} diff --git a/packages/extension/src/shared/knownDapps/IKnownDappService.ts b/packages/extension/src/shared/knownDapps/IKnownDappService.ts index 8d77fdcd0..0b8224ea9 100644 --- a/packages/extension/src/shared/knownDapps/IKnownDappService.ts +++ b/packages/extension/src/shared/knownDapps/IKnownDappService.ts @@ -1,4 +1,4 @@ -import { KnownDapp, KnownDapps } from "@argent/x-shared" +import type { KnownDapp, KnownDapps } from "@argent/x-shared" export interface IKnownDappService { getDapps: () => Promise diff --git a/packages/extension/src/shared/knownDapps/KnownDappService.ts b/packages/extension/src/shared/knownDapps/KnownDappService.ts index ba35b38df..c8e6151ee 100644 --- a/packages/extension/src/shared/knownDapps/KnownDappService.ts +++ b/packages/extension/src/shared/knownDapps/KnownDappService.ts @@ -28,6 +28,14 @@ export class KnownDappService implements IKnownDappService { return dapp ?? null } + async getDappById(dappId: string): Promise { + const knownDapps = await this.knownDappsRepository.get() + + const dapp = knownDapps?.find((knownDapp) => knownDapp.dappId === dappId) + + return dapp ?? null + } + async getDappByContractAddress( contractAddress: string, ): Promise { diff --git a/packages/extension/src/shared/knownDapps/storage.ts b/packages/extension/src/shared/knownDapps/storage.ts index 5a1246e04..b9b53238c 100644 --- a/packages/extension/src/shared/knownDapps/storage.ts +++ b/packages/extension/src/shared/knownDapps/storage.ts @@ -1,8 +1,8 @@ -import { KnownDapp } from "@argent/x-shared" +import type { KnownDapp } from "@argent/x-shared" import browser from "webextension-polyfill" import { ChromeRepository } from "../storage/__new/chrome" -import { IRepository } from "../storage/__new/interface" +import type { IRepository } from "../storage/__new/interface" export type IKnownDappsRepository = IRepository diff --git a/packages/extension/src/shared/ledger/schema.ts b/packages/extension/src/shared/ledger/schema.ts index 82dfdc13d..175ea95b3 100644 --- a/packages/extension/src/shared/ledger/schema.ts +++ b/packages/extension/src/shared/ledger/schema.ts @@ -1,5 +1,23 @@ import { z } from "zod" -export const ledgerStartContextSchema = z.enum(["create", "join", "restore"]) +/** + * Enum representing the different contexts for starting the Ledger flow. + * + * @enum {string} + * @property {string} create - Create a new multisig account with a Ledger device. + * @property {string} join - Join an existing multisig account with a Ledger device. + * @property {string} restore - Restore a multisig account with a Ledger device. + * @property {string} replace - Replace an existing signer in a multisig account with a Ledger device. + * @property {string} import - Import a standard (non-multisig) Ledger account. + * @property {string} reconnect - Reconnect a previously connected Ledger device. + */ +export const ledgerStartContextSchema = z.enum([ + "create", + "join", + "restore", + "replace", + "import", + "reconnect", +]) export type LedgerStartContext = z.infer diff --git a/packages/extension/src/shared/ledger/service/ILedgerSharedService.ts b/packages/extension/src/shared/ledger/service/ILedgerSharedService.ts index c89d7a00e..76b058980 100644 --- a/packages/extension/src/shared/ledger/service/ILedgerSharedService.ts +++ b/packages/extension/src/shared/ledger/service/ILedgerSharedService.ts @@ -1,7 +1,7 @@ -import { Address } from "@argent/x-shared" -import { PublicKeyWithIndex } from "../../signer/types" -import { LedgerSigner } from "../../signer" -import { CreateAccountType } from "../../wallet.model" +import type { Address } from "@argent/x-shared" +import type { PublicKeyWithIndex } from "../../signer/types" +import type { LedgerSigner } from "../../signer" +import type { CreateAccountType } from "../../wallet.model" export interface ILedgerSharedService { connect: () => Promise

diff --git a/packages/extension/src/shared/ledger/service/LedgerSharedService.ts b/packages/extension/src/shared/ledger/service/LedgerSharedService.ts index 02f23917e..c0ac14c1d 100644 --- a/packages/extension/src/shared/ledger/service/LedgerSharedService.ts +++ b/packages/extension/src/shared/ledger/service/LedgerSharedService.ts @@ -1,6 +1,6 @@ -import { ILedgerSharedService } from "./ILedgerSharedService" +import type { ILedgerSharedService } from "./ILedgerSharedService" +import type { Address } from "@argent/x-shared" import { - Address, addressSchema, isEqualAddress, getLedgerAccountClassHashes, @@ -8,19 +8,21 @@ import { import TransportWebHID from "@ledgerhq/hw-transport-webhid" import { StarknetClient } from "@ledgerhq/hw-app-starknet" import { LedgerSigner } from "../../signer" -import Transport from "@ledgerhq/hw-transport" +import type Transport from "@ledgerhq/hw-transport" import { AxLedgerError } from "../../errors/ledger" import { getBaseDerivationPath } from "../../signer/utils" import { getMultisigDiscoveryUrl } from "../../multisig/utils/getMultisigDiscoveryUrl" import { RecoveryError } from "../../errors/recovery" -import { PublicKeyWithIndex } from "../../signer/types" -import { CreateAccountType, SignerType } from "../../wallet.model" +import type { PublicKeyWithIndex } from "../../signer/types" +import type { CreateAccountType } from "../../wallet.model" +import { SignerType } from "../../wallet.model" import { argentXHeaders } from "../../api/headers" import { getStandardAccountDiscoveryUrl } from "../../utils/getStandardAccountDiscoveryUrl" import { z } from "zod" import { getCairo1AccountContractAddress } from "../../utils/getContractAddress" -import { INetworkService } from "../../network/service/INetworkService" -import { IMultisigBackendService } from "../../multisig/service/backend/IMultisigBackendService" +import type { INetworkService } from "../../network/service/INetworkService" +import type { IMultisigBackendService } from "../../multisig/service/backend/IMultisigBackendService" +import { getBaseMultisigAccounts } from "../../multisig/utils/baseMultisig" const NEXT_PUBLIC_KEY_BUFFER = 5 @@ -225,13 +227,18 @@ export class LedgerSharedService implements ILedgerSharedService { pubKeys.map(({ pubKey }) => pubKey), ) - // find the first public key that is not associated with an account and the index is not used + const pendingPubKeys = (await getBaseMultisigAccounts()) + .map((m) => m.pendingSigner?.pubKey) + .filter((m) => !!m) as string[] + + // find the first public key that is not associated with an account, the index is not used and it's not involved in any change signer operations nextAvailablePublicKey = pubKeys.find( ({ pubKey, index }) => !usedIndices.includes(index) && !multisigs.content.some((multisig) => multisig.signers.some((signer) => isEqualAddress(signer, pubKey)), - ), + ) && + !pendingPubKeys.some((key) => isEqualAddress(key, pubKey)), ) if (nextAvailablePublicKey) { @@ -290,10 +297,8 @@ export class LedgerSharedService implements ILedgerSharedService { try { if (!this.app) { this.transport = await TransportWebHID.create() - console.log("Transport created") const app = new StarknetClient(this.transport) this.app = app - console.log("App created") return app } return this.app diff --git a/packages/extension/src/shared/messages/AccountMessage.ts b/packages/extension/src/shared/messages/AccountMessage.ts index c00adb8f6..9b727d68a 100644 --- a/packages/extension/src/shared/messages/AccountMessage.ts +++ b/packages/extension/src/shared/messages/AccountMessage.ts @@ -1,4 +1,4 @@ -import { WalletAccount } from "../wallet.model" +import type { WalletAccount } from "../wallet.model" export type AccountMessage = | { diff --git a/packages/extension/src/shared/messages/ActionMessage.ts b/packages/extension/src/shared/messages/ActionMessage.ts index 7edb78fec..54d5d0239 100644 --- a/packages/extension/src/shared/messages/ActionMessage.ts +++ b/packages/extension/src/shared/messages/ActionMessage.ts @@ -1,5 +1,5 @@ import type { ArraySignatureType } from "starknet" -import { TypedData } from "@starknet-io/types-js" +import type { TypedData } from "@starknet-io/types-js" export interface SignMessageOptions { skipDeploy: boolean diff --git a/packages/extension/src/shared/messages/NetworkMessage.ts b/packages/extension/src/shared/messages/NetworkMessage.ts index b5ccd1a68..5c0ebea8b 100644 --- a/packages/extension/src/shared/messages/NetworkMessage.ts +++ b/packages/extension/src/shared/messages/NetworkMessage.ts @@ -1,5 +1,5 @@ -import { Network } from "../network" -import { WalletAccount } from "../wallet.model" +import type { Network } from "../network" +import type { WalletAccount } from "../wallet.model" export type NetworkMessage = | { type: "REQUEST_SELECTED_NETWORK" } diff --git a/packages/extension/src/shared/messages/PreAuthorisationMessage.ts b/packages/extension/src/shared/messages/PreAuthorisationMessage.ts index 43eb4758a..8f2848bc3 100644 --- a/packages/extension/src/shared/messages/PreAuthorisationMessage.ts +++ b/packages/extension/src/shared/messages/PreAuthorisationMessage.ts @@ -1,4 +1,4 @@ -import { WalletAccount } from "../wallet.model" +import type { WalletAccount } from "../wallet.model" export type PreAuthorisationMessage = | { type: "CONNECT_DAPP"; data?: { silent?: boolean } } diff --git a/packages/extension/src/shared/messages/TokenMessage.ts b/packages/extension/src/shared/messages/TokenMessage.ts index 5d81b54ce..fc6ce28de 100644 --- a/packages/extension/src/shared/messages/TokenMessage.ts +++ b/packages/extension/src/shared/messages/TokenMessage.ts @@ -1,4 +1,4 @@ -import { RequestToken } from "../token/__new/types/token.model" +import type { RequestToken } from "../token/__new/types/token.model" export type TokenMessage = // - used by dapps to request tokens diff --git a/packages/extension/src/shared/messages/TransactionMessage.ts b/packages/extension/src/shared/messages/TransactionMessage.ts index cdf14081d..4a2f7400a 100644 --- a/packages/extension/src/shared/messages/TransactionMessage.ts +++ b/packages/extension/src/shared/messages/TransactionMessage.ts @@ -1,14 +1,6 @@ -import type { Abi, AllowArray, Call, InvocationsDetails } from "starknet" +import type { Abi, Call, InvocationsDetails } from "starknet" -import { Transaction } from "../transactions" -import { - SimulateTransactionsRequest, - TransactionSimulationWithFees, -} from "../transactionSimulation/types" -import { DeclareContract, DeployContract } from "../udc/schema" -import { TransactionError } from "../errors/transaction" -import { EstimatedFees } from "@argent/x-shared/simulation" -import { Address } from "@argent/x-shared" +import type { Transaction } from "../transactions" export interface ExecuteTransactionRequest { transactions: Call | Call[] @@ -31,45 +23,3 @@ export type TransactionMessage = type: "TRANSACTION_FAILED" data: { actionHash: string; error?: string } } - | { type: "ESTIMATE_DECLARE_CONTRACT_FEE"; data: DeclareContract } - | { type: "ESTIMATE_DECLARE_CONTRACT_FEE_REJ"; data: { error: string } } - | { - type: "ESTIMATE_DECLARE_CONTRACT_FEE_RES" - data: EstimatedFees - } - | { - type: "ESTIMATE_DEPLOY_CONTRACT_FEE" - data: DeployContract - } - | { type: "ESTIMATE_DEPLOY_CONTRACT_FEE_REJ"; data: { error: string } } - | { - type: "ESTIMATE_DEPLOY_CONTRACT_FEE_RES" - data: EstimatedFees - } - | { - type: "SIMULATE_TRANSACTION_INVOCATION" - data: Call | Call[] - } - | { - type: "SIMULATE_TRANSACTION_INVOCATION_RES" - data: { - transactions: SimulateTransactionsRequest - chainId: string - } | null - } - | { - type: "SIMULATE_TRANSACTION_INVOCATION_REJ" - data: { error: string } - } - | { - type: "SIMULATE_TRANSACTIONS" - data: { call: AllowArray; feeTokenAddress: Address } - } - | { - type: "SIMULATE_TRANSACTIONS_RES" - data: TransactionSimulationWithFees | null - } - | { - type: "SIMULATE_TRANSACTIONS_REJ" - data: { error: TransactionError } - } diff --git a/packages/extension/src/shared/messages/UdcMessage.ts b/packages/extension/src/shared/messages/UdcMessage.ts index 1e4d19d25..d4f3673ef 100644 --- a/packages/extension/src/shared/messages/UdcMessage.ts +++ b/packages/extension/src/shared/messages/UdcMessage.ts @@ -1,4 +1,4 @@ -import { DeclareContract } from "../udc/schema" +import type { DeclareContract } from "../udc/schema" export type UdcMessage = | { diff --git a/packages/extension/src/shared/messages/getOriginFromSender.ts b/packages/extension/src/shared/messages/getOriginFromSender.ts index 4d60fa3de..0b46b00fa 100644 --- a/packages/extension/src/shared/messages/getOriginFromSender.ts +++ b/packages/extension/src/shared/messages/getOriginFromSender.ts @@ -1,4 +1,4 @@ -import browser from "webextension-polyfill" +import type browser from "webextension-polyfill" export function getOriginFromSender( sender: browser.runtime.MessageSender, diff --git a/packages/extension/src/shared/messages/isLocalhost.ts b/packages/extension/src/shared/messages/isLocalhost.ts new file mode 100644 index 000000000..aedcc3b9c --- /dev/null +++ b/packages/extension/src/shared/messages/isLocalhost.ts @@ -0,0 +1,8 @@ +export const isLocalhost = (url: string) => { + try { + const { hostname } = new URL(url) + return hostname === "localhost" || hostname === "127.0.0.1" + } catch { + return false + } +} diff --git a/packages/extension/src/shared/multicall/getMulticall.ts b/packages/extension/src/shared/multicall/getMulticall.ts index d00d51e2f..05c4a92ca 100644 --- a/packages/extension/src/shared/multicall/getMulticall.ts +++ b/packages/extension/src/shared/multicall/getMulticall.ts @@ -1,6 +1,6 @@ -import { memoize } from "lodash-es" - -import { Network, getProvider } from "../network" +import memoize from "memoizee" +import type { Network } from "../network" +import { getProvider } from "../network" import { RpcBatchProvider } from "@argent/x-multicall" import { argentXHeaders } from "../api/headers" @@ -44,5 +44,5 @@ export const getMulticallForNetwork = memoize( }) return multicall }, - (network: Network) => getMemoizeKey(network), + { normalizer: ([network]) => getMemoizeKey(network) }, ) diff --git a/packages/extension/src/shared/multisig/account.ts b/packages/extension/src/shared/multisig/account.ts index c7e23131e..96820ff10 100644 --- a/packages/extension/src/shared/multisig/account.ts +++ b/packages/extension/src/shared/multisig/account.ts @@ -1,5 +1,6 @@ -import { Address, addressSchema, txVersionSchema } from "@argent/x-shared" -import { +import type { Address } from "@argent/x-shared" +import { addressSchema, txVersionSchema } from "@argent/x-shared" +import type { Abi, Account, AccountInterface, @@ -13,32 +14,28 @@ import { InvokeFunctionResponse, ProviderInterface, Signature, - TransactionType, TypedData, UniversalDetails, V2InvocationsSignerDetails, V3InvocationsSignerDetails, - constants, - hash, - num, - stark, } from "starknet" +import { TransactionType, constants, hash, num, stark } from "starknet" import { EDataAvailabilityMode } from "@starknet-io/types-js" import { MultisigError } from "../errors/multisig" import { ArgentSigner } from "../signer/ArgentSigner" -import { BaseSignerInterface } from "../signer/BaseSignerInterface" +import type { BaseSignerInterface } from "../signer/BaseSignerInterface" import { LedgerSigner } from "../signer/LedgerSigner" import { BaseStarknetAccount } from "../starknetAccount/base" import { chainIdToArgentNetwork } from "../utils/starknetNetwork" -import { MultisigSignerSignatures } from "./multisig.model" -import { - MultisigPendingOffchainSignature, - removeMultisigPendingOffchainSignature, -} from "./pendingOffchainSignaturesStore" -import { MultisigPendingTransaction } from "./pendingTransactionsStore" -import { IMultisigBackendService } from "./service/backend/IMultisigBackendService" +import type { MultisigSignerSignatures } from "./multisig.model" +import type { MultisigPendingOffchainSignature } from "./pendingOffchainSignaturesStore" +import { removeMultisigPendingOffchainSignature } from "./pendingOffchainSignaturesStore" +import type { MultisigPendingTransaction } from "./pendingTransactionsStore" +import type { IMultisigBackendService } from "./service/backend/IMultisigBackendService" import { getMultisigAccountFromBaseWallet } from "./utils/baseMultisig" import { mapResourceBoundsToStrkBounds } from "./utils/multisigTxV3" +import { getAccountIdentifier } from "../utils/accountIdentifier" +import { addTransactionHash } from "../transactions/transactionHashes/transactionHashesRepository" export type MultisigAccountSigner = ArgentSigner | LedgerSigner @@ -124,7 +121,7 @@ export class MultisigAccount extends BaseStarknetAccount { ? transactionsDetail : abiOrDetails const transactions = Array.isArray(calls) ? calls : [calls] - const version = txVersionSchema.parse(details.version) + const version = this.getTxVersion(details) const signerDetails = await this.buildInvocationSignerDetailsPayload(details) @@ -149,6 +146,7 @@ export class MultisigAccount extends BaseStarknetAccount { const formattedSignature = await this.prependWithPublicSigner(signature) return this.multisigBackendService.createTransactionRequest({ + accountId: await this.getId(signerDetails.chainId), address: this.address, calls: transactions, transactionDetails, @@ -161,8 +159,11 @@ export class MultisigAccount extends BaseStarknetAccount { const formattedSignature = await this.prependWithPublicSigner(signature) const chainId = await this.getChainId() + const accountId = await this.getId(chainId) + const signatureRequest = await this.multisigBackendService.createOffchainSignatureRequest({ + accountId, address: this.address, data: typedData, signature: formattedSignature, @@ -184,7 +185,7 @@ export class MultisigAccount extends BaseStarknetAccount { payload: DeployAccountContractPayload, details: UniversalDetails = {}, ): Promise { - const version = txVersionSchema.parse(details.version) + const version = this.getTxVersion(details) const signerDetails = await this.buildAccountDeploySignerDetailsPayload( payload, @@ -262,10 +263,17 @@ export class MultisigAccount extends BaseStarknetAccount { } as V2InvocationsSignerDetails } + await addTransactionHash( + transactionToSign.requestId, + transactionToSign.transactionHash, + ) + const signature = await this.signer.signTransaction(calls, signerDetails) const formattedSignature = await this.prependWithPublicSigner(signature) + const accountId = await this.getId(chainId) return this.multisigBackendService.addTransactionSignature({ + accountId, address: this.address, transactionToSign, chainId, @@ -285,8 +293,11 @@ export class MultisigAccount extends BaseStarknetAccount { const formattedSignature = await this.prependWithPublicSigner(signature) + const accountId = await this.getId(chainId) + const { signatures } = await this.multisigBackendService.addOffchainSignature({ + accountId, address: this.address, pendingOffchainSignature, chainId, @@ -301,10 +312,8 @@ export class MultisigAccount extends BaseStarknetAccount { ): Promise { const chainId = await this.getChainId() - const multisig = await getMultisigAccountFromBaseWallet({ - address: this.address, - networkId: chainIdToArgentNetwork(chainId), - }) + const account = pendingOffchainSignature.account + const multisig = await getMultisigAccountFromBaseWallet(account) if (!multisig) { throw new MultisigError({ code: "MULTISIG_ACCOUNT_NOT_FOUND" }) @@ -362,4 +371,19 @@ export class MultisigAccount extends BaseStarknetAccount { const publicSigner = await this.signer.getPubKey() return [publicSigner, ...stark.signatureToHexArray(signature)] } + + private async getId(providedChainId?: constants.StarknetChainId) { + const chainId = providedChainId ?? (await this.getChainId()) + + const signer = { + type: this.signer.signerType, + derivationPath: this.signer.derivationPath, // because both ArgentSigner and LedgerSigner have this property + } + + return getAccountIdentifier( + this.address, + chainIdToArgentNetwork(chainId), + signer, + ) + } } diff --git a/packages/extension/src/shared/multisig/pendingOffchainSignaturesStore.ts b/packages/extension/src/shared/multisig/pendingOffchainSignaturesStore.ts index 91f843eda..08b2c6b1c 100644 --- a/packages/extension/src/shared/multisig/pendingOffchainSignaturesStore.ts +++ b/packages/extension/src/shared/multisig/pendingOffchainSignaturesStore.ts @@ -1,15 +1,20 @@ -import { BaseWalletAccount, baseWalletAccountSchema } from "../wallet.model" +import type { BaseWalletAccount } from "../wallet.model" +import { baseWalletAccountSchema } from "../wallet.model" import { z } from "zod" import { ApiMultisigOffchainSignatureStateSchema, multisigSignerSignaturesSchema, offchainSigMessageSchema, } from "./multisig.model" -import { addressSchema, getAccountIdentifier } from "@argent/x-shared" -import { AllowArray, IRepository, SelectorFn } from "../storage/__new/interface" +import { addressSchema } from "@argent/x-shared" +import type { + AllowArray, + IRepository, + SelectorFn, +} from "../storage/__new/interface" import { ChromeRepository } from "../storage/__new/chrome" import browser from "webextension-polyfill" -import { memoize } from "lodash-es" +import memoize from "memoizee" import { accountsEqual } from "../utils/accountsEqual" export const multisigPendingOffchainSignatureSchema = z.object({ @@ -45,7 +50,7 @@ export const byAccountSelector = memoize( (transaction: MultisigPendingOffchainSignature) => { return accountsEqual(transaction.account, account) }, - (account) => (account ? getAccountIdentifier(account) : "unknown-account"), + { normalizer: ([account]) => (account ? account.id : "unknown-account") }, ) export async function getMultisigPendingOffchainSignatures( diff --git a/packages/extension/src/shared/multisig/pendingTransactionsStore.ts b/packages/extension/src/shared/multisig/pendingTransactionsStore.ts index 0a233ea1e..f01f4d082 100644 --- a/packages/extension/src/shared/multisig/pendingTransactionsStore.ts +++ b/packages/extension/src/shared/multisig/pendingTransactionsStore.ts @@ -1,31 +1,34 @@ -import { memoize } from "lodash-es" -import { AllowArray, BigNumberish } from "starknet" +import memoize from "memoizee" +import type { AllowArray, BigNumberish } from "starknet" import { isEqualAddress } from "@argent/x-shared" import { atom } from "jotai" import { atomFamily } from "jotai/utils" import { atomFromRepo } from "../../ui/views/implementation/atomFromRepo" import { accountService } from "../account/service" -import { ActionQueueItemMeta } from "../actionQueue/schema" +import type { ActionQueueItemMeta } from "../actionQueue/schema" import { transformTransaction } from "../activity/utils/transform" import { isOnChainRejectTransaction } from "../activity/utils/transform/is" import { getTransactionFromPendingMultisigTransaction } from "../activity/utils/transform/transaction/transformers/pendingMultisigTransactionAdapter" import { ArrayStorage } from "../storage" import { adaptArrayStorage } from "../storage/__new/repository" -import { SelectorFn } from "../storage/types" -import { +import type { SelectorFn } from "../storage/types" +import type { ExtendedFinalityStatus, ExtendedTransactionType, } from "../transactions" import { addTransaction } from "../transactions/store" -import { accountsEqual, atomFamilyAccountsEqual } from "../utils/accountsEqual" -import { BaseWalletAccount, WalletAccount } from "../wallet.model" -import { getAccountIdentifier } from "../wallet.service" +import { + accountsEqual, + atomFamilyAccountsEqual, + isEqualAccountIds, +} from "../utils/accountsEqual" +import type { BaseWalletAccount, WalletAccount } from "../wallet.model" import { TransactionCreatedForMultisigPendingTransaction, multisigEmitter, } from "./emitter" -import { +import type { ApiMultisigTransaction, ApiMultisigTransactionState, } from "./multisig.model" @@ -53,7 +56,8 @@ export type MultisigPendingTransaction = { export const multisigPendingTransactionsStore = new ArrayStorage([], { namespace: "core:multisig:pendingTransactions", - compare: (a, b) => a.requestId === b.requestId, + compare: (a, b) => + a.requestId === b.requestId && accountsEqual(a.account, b.account), }) export const multisigPendingTransactionsRepo = adaptArrayStorage( @@ -94,7 +98,7 @@ export const byAccountSelector = memoize( (transaction: MultisigPendingTransaction) => { return accountsEqual(transaction.account, account) }, - (account) => (account ? getAccountIdentifier(account) : "unknown-account"), + { normalizer: ([account]) => (account ? account.id : "unknown-account") }, ) export async function getMultisigPendingTransactions( @@ -171,8 +175,9 @@ export async function removeRejectedOnChainPendingTransactions( const accounts: WalletAccount[] = await accountService.getFromBaseWalletAccounts( Object.keys(groupedTransactionsByAccount).map((accountKey) => { - const [address, networkId] = accountKey.split("/") + const [address, networkId] = accountKey.split("-") return { + id: accountKey, address, networkId, } @@ -196,12 +201,10 @@ const groupTransactionsByAccount = ( ) => { return allTransactions.reduce( (groups: { [key: string]: MultisigPendingTransaction[] }, transaction) => { - // Serialize the account object to a string key - const accountKey = `${transaction.account.address}/${transaction.account.networkId}` + const accountKey = transaction.account.id // Check if a group for this account already exists for (const key in groups) { - const accountAddress = key.split("/")[0] - if (isEqualAddress(transaction.account.address, accountAddress)) { + if (isEqualAccountIds(accountKey, key)) { groups[key].push(transaction) return groups } diff --git a/packages/extension/src/shared/multisig/repository.ts b/packages/extension/src/shared/multisig/repository.ts index 64c703a28..def2b5558 100644 --- a/packages/extension/src/shared/multisig/repository.ts +++ b/packages/extension/src/shared/multisig/repository.ts @@ -1,6 +1,6 @@ -import { IRepository } from "../storage/__new/interface" -import { BaseMultisigWalletAccount } from "../wallet.model" -import { +import type { IRepository } from "../storage/__new/interface" +import type { BaseMultisigWalletAccount } from "../wallet.model" +import type { IMultisigBaseWalletRepositary, MultisigMetadata, PendingMultisig, diff --git a/packages/extension/src/shared/multisig/service/backend/IMultisigBackendService.ts b/packages/extension/src/shared/multisig/service/backend/IMultisigBackendService.ts index 640a834cf..f874e15ba 100644 --- a/packages/extension/src/shared/multisig/service/backend/IMultisigBackendService.ts +++ b/packages/extension/src/shared/multisig/service/backend/IMultisigBackendService.ts @@ -1,6 +1,6 @@ -import { InvokeFunctionResponse } from "starknet" -import { BaseWalletAccount } from "../../../wallet.model" -import { +import type { InvokeFunctionResponse } from "starknet" +import type { BaseWalletAccount } from "../../../wallet.model" +import type { ApiMultisigAccountData, ApiMultisigDataForSigner, ApiMultisigGetSignatureRequestById, @@ -8,7 +8,7 @@ import { ApiMultisigGetTransactionRequests, MultisigSignerSignaturesWithId, } from "../../multisig.model" -import { +import type { ICreateTransactionRequest, IAddRequestSignature, IFetchMultisigDataForSigner, @@ -17,7 +17,7 @@ import { IFetchMultisigOffchainSignatureRequestById, ICancelOffchainSignature, } from "./types" -import { Network } from "../../../network" +import type { Network } from "../../../network" export interface IMultisigBackendService { fetchMultisigDataForSigner( diff --git a/packages/extension/src/shared/multisig/service/backend/MultisigBackendService.test.ts b/packages/extension/src/shared/multisig/service/backend/MultisigBackendService.test.ts index 15891c4b8..506e7fe85 100644 --- a/packages/extension/src/shared/multisig/service/backend/MultisigBackendService.test.ts +++ b/packages/extension/src/shared/multisig/service/backend/MultisigBackendService.test.ts @@ -1,7 +1,7 @@ import { constants, stark } from "starknet" -import { MockedFunction } from "vitest" +import type { MockedFunction } from "vitest" import { getMockNetwork } from "../../../../../test/network.mock" -import { +import type { ApiMultisigAccountData, ApiMultisigDataForSigner, ApiMultisigGetTransactionRequests, @@ -9,9 +9,13 @@ import { } from "../../multisig.model" import { MultisigBackendService } from "./MultisigBackendService" import { getMultisigAccountFromBaseWallet } from "../../utils/baseMultisig" -import { getMockAccount } from "../../../../../test/account.mock" +import { getMockAccount, getMockSigner } from "../../../../../test/account.mock" import { chainIdToStarknetNetwork } from "../../../utils/starknetNetwork" import { addressSchema } from "@argent/x-shared" +import { + getAccountIdentifier, + getRandomAccountIdentifier, +} from "../../../utils/accountIdentifier" vi.mock("../../utils/baseMultisig") vi.mock("../../pendingTransactionsStore") @@ -26,6 +30,8 @@ const mockGetMultisigAccountFromBaseWallet = const address = addressSchema.parse(stark.randomAddress()) const creator = addressSchema.parse(stark.randomAddress()) +const id = getAccountIdentifier(address, getMockNetwork().id, getMockSigner()) + describe("MultisigBackendService", () => { const mockFetcher = vi.fn() let mockCurrentTime @@ -137,6 +143,7 @@ describe("MultisigBackendService", () => { ) const response = await service.fetchMultisigAccountData({ + id, address, networkId: getMockNetwork().id, }) @@ -173,6 +180,7 @@ describe("MultisigBackendService", () => { await expect( service.fetchMultisigAccountData({ + id, address, networkId: getMockNetwork().id, }), @@ -211,6 +219,7 @@ describe("MultisigBackendService", () => { ) const response = await service.fetchMultisigTransactionRequests({ + id, address: address, networkId: getMockNetwork().id, }) @@ -241,6 +250,7 @@ describe("MultisigBackendService", () => { await expect( service.fetchMultisigTransactionRequests({ + id, address, networkId: getMockNetwork().id, }), @@ -265,6 +275,7 @@ describe("MultisigBackendService", () => { signers: ["0x123"], publicKey: "0x123", updatedAt: 123, + type: "multisig", }) const payload = { creator, @@ -311,6 +322,7 @@ describe("MultisigBackendService", () => { ) const returnValue = await service.createTransactionRequest({ + accountId: id, address: address, signature: [ BigInt(creator).toString(), @@ -362,14 +374,18 @@ describe("MultisigBackendService", () => { const creator = "0x03ae16dac8ab10a29cb58a96051ba6b3b10d66afc327887105fd90c05486c24b" + const id = getRandomAccountIdentifier(address) + mockGetMultisigAccountFromBaseWallet.mockResolvedValueOnce({ ...getMockAccount({ + id, address, }), threshold: 2, signers: ["0x123"], publicKey: "0x123", updatedAt: 123, + type: "multisig", }) const payload = { creator, @@ -417,6 +433,7 @@ describe("MultisigBackendService", () => { ) const returnValue = await service.createTransactionRequest({ + accountId: id, address: address, signature: [ BigInt(creator).toString(), @@ -463,6 +480,7 @@ describe("MultisigBackendService", () => { expect(addToMultisigPendingTransactionsSpy).toHaveBeenCalledTimes(1) expect(addToMultisigPendingTransactionsSpy).toHaveBeenCalledWith({ account: { + id, address, networkId: "sepolia-alpha", }, @@ -491,16 +509,21 @@ describe("MultisigBackendService", () => { describe("addRequestSignature", () => { it("should call the correct endpoint with the correct payload and return the correct hash", async () => { - const address = "0x1" + const address = + "0x0590374e464c0e1d8078ee2f1556d99e46d28e0f90788305f4e2b34df53950b8" + + const id = getRandomAccountIdentifier(address) mockGetMultisigAccountFromBaseWallet.mockResolvedValueOnce({ ...getMockAccount({ + id, address, }), threshold: 2, signers: ["0x123"], publicKey: "0x123", updatedAt: 123, + type: "multisig", }) const expectedRes = { @@ -534,6 +557,7 @@ describe("MultisigBackendService", () => { const requestId = "0x6969" await service.addTransactionSignature({ address: address, + accountId: id, signature: [ BigInt(45602318).toString(), BigInt(12354654).toString(), @@ -542,7 +566,9 @@ describe("MultisigBackendService", () => { ], transactionToSign: { account: { - address: "0x1", + id, + address: + "0x0590374e464c0e1d8078ee2f1556d99e46d28e0f90788305f4e2b34df53950b8", networkId: "sepolia-alpha", }, approvedSigners: ["0x123"], @@ -584,7 +610,9 @@ describe("MultisigBackendService", () => { expect(addToMultisigPendingTransactionsSpy).toHaveBeenCalledTimes(1) expect(addToMultisigPendingTransactionsSpy).toHaveBeenCalledWith({ account: { - address: "0x1", + id, + address: + "0x0590374e464c0e1d8078ee2f1556d99e46d28e0f90788305f4e2b34df53950b8", networkId: "sepolia-alpha", }, approvedSigners: ["0x123"], @@ -617,6 +645,7 @@ describe("MultisigBackendService", () => { signers: ["0x123"], publicKey: "0x123", updatedAt: 123, + type: "multisig", }) const expectedRes = { @@ -650,6 +679,7 @@ describe("MultisigBackendService", () => { ) const requestId = "0x6969" await service.addTransactionSignature({ + accountId: id, address: address, signature: [ BigInt(45602318).toString(), @@ -659,6 +689,7 @@ describe("MultisigBackendService", () => { ], transactionToSign: { account: { + id, address: "0x1", networkId: "sepolia-alpha", }, diff --git a/packages/extension/src/shared/multisig/service/backend/MultisigBackendService.ts b/packages/extension/src/shared/multisig/service/backend/MultisigBackendService.ts index 465912647..771b8c395 100644 --- a/packages/extension/src/shared/multisig/service/backend/MultisigBackendService.ts +++ b/packages/extension/src/shared/multisig/service/backend/MultisigBackendService.ts @@ -1,19 +1,12 @@ -import { - AllowArray, - InvokeFunctionResponse, - Signature, - num, - stark, - transaction, - v2hash, -} from "starknet" +import type { AllowArray, InvokeFunctionResponse, Signature } from "starknet" +import { num, stark, transaction, v2hash } from "starknet" import urlJoin from "url-join" import { ARGENT_MULTISIG_DISCOVERY_URL } from "../../../api/constants" import { fetcher } from "../../../api/fetcher" import { argentXHeaders } from "../../../api/headers" import { MultisigError } from "../../../errors/multisig" import { RecoveryError } from "../../../errors/recovery" -import { Network } from "../../../network" +import type { Network } from "../../../network" import { chainIdToStarknetNetwork, networkIdToStarknetNetwork, @@ -21,22 +14,27 @@ import { starknetNetworkToNetworkId, } from "../../../utils/starknetNetwork" import { urlWithQuery } from "../../../utils/url" -import { BaseWalletAccount, MultisigWalletAccount } from "../../../wallet.model" -import { +import type { + BaseWalletAccount, + MultisigWalletAccount, +} from "../../../wallet.model" +import type { ApiMultisigAccountData, - ApiMultisigAccountDataSchema, - ApiMultisigAddRequestSignatureSchema, ApiMultisigDataForSigner, - ApiMultisigDataForSignerSchema, ApiMultisigGetSignatureRequestById, ApiMultisigGetSignatureRequests, ApiMultisigGetTransactionRequests, - ApiMultisigGetTransactionRequestsSchema, ApiMultisigPostRequestTxn, - ApiMultisigPostRequestTxnSchema, - ApiMultisigTransactionResponseSchema, ApiMultisigTransactionState, MultisigSignerSignaturesWithId, +} from "../../multisig.model" +import { + ApiMultisigAccountDataSchema, + ApiMultisigAddRequestSignatureSchema, + ApiMultisigDataForSignerSchema, + ApiMultisigGetTransactionRequestsSchema, + ApiMultisigPostRequestTxnSchema, + ApiMultisigTransactionResponseSchema, apiMultisigCancelOffchainSignatureRequestSchema, apiMultisigGetSignatureRequestByIdSchema, apiMultisigGetSignatureRequestsSchema, @@ -44,19 +42,19 @@ import { createOffchainSignatureResponseSchema, multisigSignerSignatureSchema, } from "../../multisig.model" +import type { MultisigPendingOffchainSignature } from "../../pendingOffchainSignaturesStore" import { - MultisigPendingOffchainSignature, addMultisigPendingOffchainSignatures, removeMultisigPendingOffchainSignature, } from "../../pendingOffchainSignaturesStore" +import type { MultisigPendingTransaction } from "../../pendingTransactionsStore" import { - MultisigPendingTransaction, addToMultisigPendingTransactions, multisigPendingTransactionToTransaction, } from "../../pendingTransactionsStore" import { getMultisigAccountFromBaseWallet } from "../../utils/baseMultisig" -import { IMultisigBackendService } from "./IMultisigBackendService" -import { +import type { IMultisigBackendService } from "./IMultisigBackendService" +import type { IAddOffchainSignature, IAddRequestSignature, ICancelOffchainSignature, @@ -215,11 +213,12 @@ export class MultisigBackendService implements IMultisigBackendService { private mapTransactionDetails({ transactionDetails, address, + accountId, }: IMapTransactionDetails): MappedTransactionDetails { const { nonce, version, chainId, cairoVersion } = transactionDetails const starknetNetwork = chainIdToStarknetNetwork(chainId) const networkId = starknetNetworkToNetworkId(starknetNetwork) - const account: BaseWalletAccount = { address, networkId } + const account: BaseWalletAccount = { address, networkId, id: accountId } const maxFee = "maxFee" in transactionDetails ? transactionDetails.maxFee : 0 // not exists in V3InvocationsSignerDetails const resourceBounds = @@ -301,6 +300,7 @@ export class MultisigBackendService implements IMultisigBackendService { async createTransactionRequest({ address, + accountId, signature, calls, transactionDetails, @@ -314,7 +314,7 @@ export class MultisigBackendService implements IMultisigBackendService { account, starknetNetwork, resourceBounds, - } = this.mapTransactionDetails({ transactionDetails, address }) + } = this.mapTransactionDetails({ transactionDetails, address, accountId }) const multisig = await this.fetchMultisigAccount(account) const request = await this.prepareTransaction({ @@ -400,10 +400,12 @@ export class MultisigBackendService implements IMultisigBackendService { transactionToSign, chainId, signature, + accountId, }: IAddRequestSignature): Promise { const starknetNetwork = chainIdToStarknetNetwork(chainId) const networkId = starknetNetworkToNetworkId(starknetNetwork) const multisig = await this.fetchMultisigAccount({ + id: accountId, address, networkId, }) @@ -440,10 +442,12 @@ export class MultisigBackendService implements IMultisigBackendService { data, signature, chainId, + accountId, }: ICreateOffchainSignatureRequest): Promise { const starknetNetwork = chainIdToStarknetNetwork(chainId) const networkId = starknetNetworkToNetworkId(starknetNetwork) const multisig = await this.fetchMultisigAccount({ + id: accountId, address, networkId, }) @@ -496,6 +500,7 @@ export class MultisigBackendService implements IMultisigBackendService { const starknetNetwork = chainIdToStarknetNetwork(chainId) const networkId = starknetNetworkToNetworkId(starknetNetwork) const multisig = await this.fetchMultisigAccount({ + id: payload.accountId, address, networkId, }) diff --git a/packages/extension/src/shared/multisig/service/backend/types.ts b/packages/extension/src/shared/multisig/service/backend/types.ts index 67604124e..d254a51e6 100644 --- a/packages/extension/src/shared/multisig/service/backend/types.ts +++ b/packages/extension/src/shared/multisig/service/backend/types.ts @@ -1,5 +1,5 @@ -import { Address } from "@argent/x-shared" -import { +import type { Address } from "@argent/x-shared" +import type { BigNumberish, CairoVersion, Call, @@ -7,12 +7,16 @@ import { Signature, constants, } from "starknet" -import { TypedData } from "@starknet-io/types-js" -import { Network } from "../../../network" -import { BaseWalletAccount, MultisigWalletAccount } from "../../../wallet.model" -import { ApiMultisigResourceBounds } from "../../multisig.model" -import { MultisigPendingOffchainSignature } from "../../pendingOffchainSignaturesStore" -import { MultisigPendingTransaction } from "../../pendingTransactionsStore" +import type { TypedData } from "@starknet-io/types-js" +import type { Network } from "../../../network" +import type { + AccountId, + BaseWalletAccount, + MultisigWalletAccount, +} from "../../../wallet.model" +import type { ApiMultisigResourceBounds } from "../../multisig.model" +import type { MultisigPendingOffchainSignature } from "../../pendingOffchainSignaturesStore" +import type { MultisigPendingTransaction } from "../../pendingTransactionsStore" export interface IFetchMultisigDataForSigner { signer: string @@ -27,6 +31,7 @@ export interface IFetchMultisigOffchainSignatureRequestById { export interface ICreateTransactionRequest { address: Address + accountId: AccountId calls: Call[] transactionDetails: InvocationsSignerDetails signature: Signature @@ -34,6 +39,7 @@ export interface ICreateTransactionRequest { export interface ICreateOffchainSignatureRequest { address: Address + accountId: AccountId data: TypedData signature: Signature chainId: constants.StarknetChainId @@ -41,6 +47,7 @@ export interface ICreateOffchainSignatureRequest { export interface IAddOffchainSignature { address: Address + accountId: AccountId signature: Signature chainId: constants.StarknetChainId pendingOffchainSignature: MultisigPendingOffchainSignature @@ -55,11 +62,13 @@ export interface ICancelOffchainSignature { export interface IMapTransactionDetails { address: Address + accountId: AccountId transactionDetails: InvocationsSignerDetails } export interface IAddRequestSignature { address: Address + accountId: AccountId transactionToSign: MultisigPendingTransaction chainId: constants.StarknetChainId signature: Signature diff --git a/packages/extension/src/shared/multisig/service/messaging/IMultisigService.ts b/packages/extension/src/shared/multisig/service/messaging/IMultisigService.ts index a8d9d5ebd..bb3dd8a1c 100644 --- a/packages/extension/src/shared/multisig/service/messaging/IMultisigService.ts +++ b/packages/extension/src/shared/multisig/service/messaging/IMultisigService.ts @@ -1,4 +1,4 @@ -import { +import type { AddAccountPayload, AddOwnerMultisigPayload, MultisigSignerSignatures, @@ -6,12 +6,12 @@ import { ReplaceOwnerMultisigPayload, UpdateMultisigThresholdPayload, } from "../../multisig.model" -import { +import type { BaseWalletAccount, SignerType, WalletAccount, } from "../../../wallet.model" -import { PendingMultisig } from "../../types" +import type { PendingMultisig } from "../../types" export interface AddAccountResponse { account: WalletAccount diff --git a/packages/extension/src/shared/multisig/signer.ts b/packages/extension/src/shared/multisig/signer.ts index 300d533b9..578ed55d8 100644 --- a/packages/extension/src/shared/multisig/signer.ts +++ b/packages/extension/src/shared/multisig/signer.ts @@ -1,14 +1,12 @@ -import { +import type { Call, DeployAccountSignerDetails, InvocationsSignerDetails, Signature, - Signer, TypedData, - stark, - hash, } from "starknet" -import { ApiMultisigOffchainSignatureState } from "./multisig.model" +import { Signer, stark, hash } from "starknet" +import type { ApiMultisigOffchainSignatureState } from "./multisig.model" export class MultisigSigner extends Signer { constructor(pk: Uint8Array | string) { diff --git a/packages/extension/src/shared/multisig/types.ts b/packages/extension/src/shared/multisig/types.ts index 3fafb7302..b01851423 100644 --- a/packages/extension/src/shared/multisig/types.ts +++ b/packages/extension/src/shared/multisig/types.ts @@ -1,5 +1,8 @@ -import { IRepository } from "../storage/__new/interface" -import { BaseMultisigWalletAccount, WalletAccountSigner } from "../wallet.model" +import type { IRepository } from "../storage/__new/interface" +import type { + BaseMultisigWalletAccount, + WalletAccountSigner, +} from "../wallet.model" export const enum MultisigEntryPointType { // read diff --git a/packages/extension/src/shared/multisig/utils/baseMultisig.ts b/packages/extension/src/shared/multisig/utils/baseMultisig.ts index 36b85229b..5ecc3d470 100644 --- a/packages/extension/src/shared/multisig/utils/baseMultisig.ts +++ b/packages/extension/src/shared/multisig/utils/baseMultisig.ts @@ -1,11 +1,11 @@ -import { AllowArray } from "starknet" +import type { AllowArray } from "starknet" import { getLatestArgentMultisigClassHash } from "@argent/x-shared" import { withoutHiddenSelector } from "../../account/selectors" import { accountService } from "../../account/service" -import { SelectorFn } from "../../storage/types" +import type { SelectorFn } from "../../storage/types" import { accountsEqual } from "../../utils/accountsEqual" -import { +import type { BaseMultisigWalletAccount, BaseWalletAccount, MultisigWalletAccount, @@ -75,6 +75,7 @@ export async function addMultisigAccount( account: MultisigWalletAccount, ): Promise { await accountService.upsert({ + id: account.id, address: account.address, name: account.name, network: account.network, @@ -90,6 +91,7 @@ export async function addMultisigAccount( }) await addBaseMultisigAccounts({ + id: account.id, address: account.address, networkId: account.networkId, publicKey: account.publicKey, @@ -103,7 +105,7 @@ export async function addMultisigAccount( export async function hideMultisig( baseAccount: BaseMultisigWalletAccount, ): Promise { - await accountService.setHide(true, baseAccount) + await accountService.setHide(true, baseAccount.id) } export async function updateBaseMultisigAccount( @@ -121,5 +123,14 @@ export async function removeMultisigAccount( accountsEqual(account, baseAccount), ) - await accountService.remove(baseAccount) + await accountService.removeById(baseAccount.id) +} + +export async function getBaseMultisigAccount( + baseWalletAccount: BaseWalletAccount, +): Promise { + const accounts = await multisigBaseWalletRepo.get((account) => + accountsEqual(account, baseWalletAccount), + ) + return accounts[0] } diff --git a/packages/extension/src/shared/multisig/utils/getMultisigDiscoveryUrl.ts b/packages/extension/src/shared/multisig/utils/getMultisigDiscoveryUrl.ts index 7959a3e66..ddf4d5831 100644 --- a/packages/extension/src/shared/multisig/utils/getMultisigDiscoveryUrl.ts +++ b/packages/extension/src/shared/multisig/utils/getMultisigDiscoveryUrl.ts @@ -1,7 +1,7 @@ import urlJoin from "url-join" import { ARGENT_MULTISIG_DISCOVERY_URL } from "../../api/constants" import { RecoveryError } from "../../errors/recovery" -import { Network } from "../../network" +import type { Network } from "../../network" import { networkIdToStarknetNetwork } from "../../utils/starknetNetwork" export function getMultisigDiscoveryUrl(network: Network) { diff --git a/packages/extension/src/shared/multisig/utils/getMultisigTransactionType.test.ts b/packages/extension/src/shared/multisig/utils/getMultisigTransactionType.test.ts index 45581ee40..d74e8803b 100644 --- a/packages/extension/src/shared/multisig/utils/getMultisigTransactionType.test.ts +++ b/packages/extension/src/shared/multisig/utils/getMultisigTransactionType.test.ts @@ -1,4 +1,4 @@ -import { ApiMultisigRequest } from "../multisig.model" +import type { ApiMultisigRequest } from "../multisig.model" import multisigRequests from "./fixtures/multisigRequests.json" import multisigRequestsWithRejectedTx from "./fixtures/multisigRequestsWithRejected.json" import multisigRequestsWithRetriedTx from "./fixtures/multisigRequestsWithRetried.json" diff --git a/packages/extension/src/shared/multisig/utils/getMultisigTransactionType.ts b/packages/extension/src/shared/multisig/utils/getMultisigTransactionType.ts index d3a88a4a1..c75ae32ea 100644 --- a/packages/extension/src/shared/multisig/utils/getMultisigTransactionType.ts +++ b/packages/extension/src/shared/multisig/utils/getMultisigTransactionType.ts @@ -1,7 +1,7 @@ import { isEqualAddress } from "@argent/x-shared" -import { Call } from "starknet" +import type { Call } from "starknet" import { MultisigEntryPointType, MultisigTransactionType } from "../types" -import { +import type { ApiMultisigRequest, ApiMultisigTransactionState, } from "../multisig.model" diff --git a/packages/extension/src/shared/multisig/utils/multisigTxV3.ts b/packages/extension/src/shared/multisig/utils/multisigTxV3.ts index d32044f3d..17c6e3870 100644 --- a/packages/extension/src/shared/multisig/utils/multisigTxV3.ts +++ b/packages/extension/src/shared/multisig/utils/multisigTxV3.ts @@ -1,6 +1,6 @@ import { num } from "starknet" -import { ResourceBounds } from "@starknet-io/types-js" -import { ApiMultisigResourceBounds } from "../multisig.model" +import type { ResourceBounds } from "@starknet-io/types-js" +import type { ApiMultisigResourceBounds } from "../multisig.model" const DEFAULT_BOUNDS = { max_amount: "0x0", diff --git a/packages/extension/src/shared/multisig/utils/pendingMultisig.ts b/packages/extension/src/shared/multisig/utils/pendingMultisig.ts index c6f58d107..d7f3249cd 100644 --- a/packages/extension/src/shared/multisig/utils/pendingMultisig.ts +++ b/packages/extension/src/shared/multisig/utils/pendingMultisig.ts @@ -1,21 +1,21 @@ -import { AllowArray } from "starknet" +import type { AllowArray } from "starknet" import { networkService } from "../../network/service" -import { SelectorFn } from "../../storage/types" -import { +import type { SelectorFn } from "../../storage/types" +import type { BaseMultisigWalletAccount, MultisigWalletAccount, } from "../../wallet.model" import { pendingMultisigRepo } from "../repository" -import { BasePendingMultisig, PendingMultisig } from "../types" +import type { BasePendingMultisig, PendingMultisig } from "../types" import { addMultisigAccount } from "./baseMultisig" import { getPendingMultisigSelector, pendingMultisigEqual, withoutHiddenPendingMultisig, } from "./selectors" +import { getAccountIdentifier } from "../../utils/accountIdentifier" import { getAccountClassHashFromChain } from "../../account/details" -import { isEqualAddress } from "@argent/x-shared" import { accountsEqual } from "../../utils/accountsEqual" export async function getAllPendingMultisigs( @@ -48,12 +48,8 @@ export async function removePendingMultisig( throw new Error("Pending multisig to remove not found") } - return pendingMultisigRepo.remove( - // pendingMultisigEqual(pendingMultisig, basePendingMultisig), - (multisig) => pendingMultisigEqual(multisig, basePendingMultisig), - // multisig.name === pendingMultisig.name && - // multisig.networkId === pendingMultisig.networkId && - // multisig.publicKey === pendingMultisig.publicKey, + return pendingMultisigRepo.remove((multisig) => + pendingMultisigEqual(multisig, basePendingMultisig), ) } @@ -69,7 +65,14 @@ export async function pendingMultisigToMultisig( throw new Error("Pending multisig to convert to Multisig not found") } + const id = getAccountIdentifier( + multisigData.address, + multisigData.networkId, + pendingMultisig.signer, + ) + const fullMultisig: MultisigWalletAccount = { + id, address: multisigData.address, name: pendingMultisig.name, type: "multisig", diff --git a/packages/extension/src/shared/multisig/utils/selectors.ts b/packages/extension/src/shared/multisig/utils/selectors.ts index e2d9883e0..e06540595 100644 --- a/packages/extension/src/shared/multisig/utils/selectors.ts +++ b/packages/extension/src/shared/multisig/utils/selectors.ts @@ -1,6 +1,5 @@ -import { memoize } from "lodash-es" - -import { BasePendingMultisig, PendingMultisig } from "../types" +import memoize from "memoizee" +import type { BasePendingMultisig, PendingMultisig } from "../types" export const pendingMultisigEqual = ( a: BasePendingMultisig, @@ -10,13 +9,13 @@ export const pendingMultisigEqual = ( export const getPendingMultisigSelector = memoize( (base: BasePendingMultisig) => (multisig: PendingMultisig) => pendingMultisigEqual(multisig, base), + { normalizer: ([base]) => `${base.publicKey}::${base.networkId}` }, ) export const withoutHiddenPendingMultisig = ( pendingMultisig: PendingMultisig, ) => !pendingMultisig.hidden -export const withHiddenPendingMultisig = memoize( - () => true, - () => "default", -) +export const withHiddenPendingMultisig = memoize(() => true, { + normalizer: () => "default", +}) diff --git a/packages/extension/src/shared/network/FallbackRpcProvider.test.ts b/packages/extension/src/shared/network/FallbackRpcProvider.test.ts index 43ae607f8..ca87de76a 100644 --- a/packages/extension/src/shared/network/FallbackRpcProvider.test.ts +++ b/packages/extension/src/shared/network/FallbackRpcProvider.test.ts @@ -25,7 +25,9 @@ describe("FallbackRpcProvider", () => { /** wait fetch to chainId() in constructor to resolve */ await delay(0) /** reset the mock by default */ - mockReset && fetchImplementation.mockReset() + if (mockReset) { + fetchImplementation.mockReset() + } return { nodeUrls, fetchImplementation, diff --git a/packages/extension/src/shared/network/FallbackRpcProvider.ts b/packages/extension/src/shared/network/FallbackRpcProvider.ts index 84c7335e3..5bc4335ba 100644 --- a/packages/extension/src/shared/network/FallbackRpcProvider.ts +++ b/packages/extension/src/shared/network/FallbackRpcProvider.ts @@ -1,9 +1,5 @@ -import { - RpcChannel, - RpcProvider, - RpcProviderOptions, - json as starknetJson, -} from "starknet" +import type { RpcProviderOptions } from "starknet" +import { RpcChannel, RpcProvider, json as starknetJson } from "starknet" import { delay } from "../utils/delay" import { exponentialBackoff } from "./exponentialBackoff" import { shuffle } from "lodash-es" diff --git a/packages/extension/src/shared/network/FallbackRpcProvider5.ts b/packages/extension/src/shared/network/FallbackRpcProvider5.ts index 7cb8b5f1a..ed83b308d 100644 --- a/packages/extension/src/shared/network/FallbackRpcProvider5.ts +++ b/packages/extension/src/shared/network/FallbackRpcProvider5.ts @@ -1,9 +1,5 @@ -import { - RpcProvider, - RpcProviderOptions, - constants, - json as starknetJson, -} from "starknet5" +import type { RpcProviderOptions, constants } from "starknet5" +import { RpcProvider, json as starknetJson } from "starknet5" import { delay } from "../utils/delay" import { exponentialBackoff } from "./exponentialBackoff" import { shuffle } from "lodash-es" diff --git a/packages/extension/src/shared/network/constants.ts b/packages/extension/src/shared/network/constants.ts index 3fe95cdbb..092c962e1 100644 --- a/packages/extension/src/shared/network/constants.ts +++ b/packages/extension/src/shared/network/constants.ts @@ -1,15 +1,18 @@ -import { PublicRpcNode } from "./type" +import type { PublicRpcNode } from "./type" export const ETH_TOKEN_ADDRESS = "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7" export const STRK_TOKEN_ADDRESS = "0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d" +export const USDC_TOKEN_ADDRESS = + "0x053c91253bc9682c04929ca02ed00b3e423f6710d2ee7e0d5ebb06f3ecf368a8" + export const STANDARD_CAIRO_0_ACCOUNT_CLASS_HASH = "0x033434ad846cdd5f23eb73ff09fe6fddd568284a0fb7d1be20ee482f044dabe2" export const STANDARD_DEVNET_ACCOUNT_CLASS_HASH = - "0x4d07e40e93398ed3c76981e72dd1fd22557a78ce36c0515f679e27f0bb5bc5f" + "0x61dac032f228abef9c6626f995015233097ae253a7f72d68552db02f2971b8f" export const PLUGIN_ACCOUNT_CLASS_HASH = "0x4ee23ad83fb55c1e3fac26e2cd951c60abf3ddc851caa9a7fbb9f5eddb2091" diff --git a/packages/extension/src/shared/network/defaults.ts b/packages/extension/src/shared/network/defaults.ts index 7da547f36..3103f307b 100644 --- a/packages/extension/src/shared/network/defaults.ts +++ b/packages/extension/src/shared/network/defaults.ts @@ -7,7 +7,6 @@ import { ETH_TOKEN_ADDRESS, MULTICALL_CONTRACT_ADDRESS, STANDARD_CAIRO_0_ACCOUNT_CLASS_HASH, - STANDARD_DEVNET_ACCOUNT_CLASS_HASH, STRK_TOKEN_ADDRESS, } from "./constants" import type { Network, NetworkWithStatus } from "./type" @@ -86,14 +85,13 @@ export const defaultNetworks: Network[] = [ ...(process.env.NODE_ENV === "development" ? NODE_ENV_DEV_ONLY_NETWORKS : []), { id: "localhost", - chainId: "SN_GOERLI", + chainId: "SN_SEPOLIA", rpcUrl: "http://localhost:5050", explorerUrl: "http://localhost:4000/testnet/", name: "Devnet", - possibleFeeTokenAddresses: [ETH_TOKEN_ADDRESS], + possibleFeeTokenAddresses: [ETH_TOKEN_ADDRESS, STRK_TOKEN_ADDRESS], accountClassHash: { - standard: STANDARD_DEVNET_ACCOUNT_CLASS_HASH, - smart: STANDARD_DEVNET_ACCOUNT_CLASS_HASH, + standard: TXV3_ACCOUNT_CLASS_HASH, }, }, ] diff --git a/packages/extension/src/shared/network/index.ts b/packages/extension/src/shared/network/index.ts index 9b7f7e770..9cde463ef 100644 --- a/packages/extension/src/shared/network/index.ts +++ b/packages/extension/src/shared/network/index.ts @@ -3,6 +3,6 @@ export { defaultNetworks, defaultCustomNetworks, } from "./defaults" -export { getProvider, getProvider5 } from "./provider" +export { getProvider } from "./provider" export { networkSchema } from "./schema" export type { Network, ColorStatus } from "./type" diff --git a/packages/extension/src/shared/network/makeSafeNetworks.test.ts b/packages/extension/src/shared/network/makeSafeNetworks.test.ts index 187d53b04..acdb12c33 100644 --- a/packages/extension/src/shared/network/makeSafeNetworks.test.ts +++ b/packages/extension/src/shared/network/makeSafeNetworks.test.ts @@ -2,7 +2,7 @@ import {} from "vitest" import { makeSafeNetworks } from "./makeSafeNetworks" import { defaultNetworks } from "./defaults" -import { Network } from "." +import type { Network } from "." import { ETH_TOKEN_ADDRESS } from "./constants" const legacyNetwork = { diff --git a/packages/extension/src/shared/network/makeSafeNetworks.ts b/packages/extension/src/shared/network/makeSafeNetworks.ts index 26e165149..3c1cecfd3 100644 --- a/packages/extension/src/shared/network/makeSafeNetworks.ts +++ b/packages/extension/src/shared/network/makeSafeNetworks.ts @@ -1,4 +1,4 @@ -import { Address } from "@argent/x-shared" +import type { Address } from "@argent/x-shared" import type { Network } from "./type" import { ETH_TOKEN_ADDRESS } from "./constants" import { networkSchema } from "." diff --git a/packages/extension/src/shared/network/provider.ts b/packages/extension/src/shared/network/provider.ts index dba13ec91..f3da8f60e 100644 --- a/packages/extension/src/shared/network/provider.ts +++ b/packages/extension/src/shared/network/provider.ts @@ -1,9 +1,8 @@ -import { memoize } from "lodash-es" -import { RpcProvider as RpcProvider5 } from "starknet5" -import { RpcProvider as RpcProvider, shortString, constants } from "starknet" -import { RpcProvider as RpcProviderV4 } from "starknet4" +import memoize from "memoizee" +import type { constants } from "starknet" +import { RpcProvider as RpcProvider, shortString } from "starknet" -import { Network } from "./type" +import type { Network } from "./type" import { argentXHeaders } from "../api/headers" export const getProviderForRpcUrl = memoize( @@ -14,18 +13,7 @@ export const getProviderForRpcUrl = memoize( headers: argentXHeaders, }) }, - (a: string, b: string = "") => `${a}::${b}`, -) - -export const getProviderForRpcUrl5 = memoize( - (rpcUrl: string, chainId?: constants.StarknetChainId): RpcProvider5 => { - return new RpcProvider5({ - nodeUrl: rpcUrl, - chainId, - headers: argentXHeaders, - }) - }, - (a: string, b: string = "") => `${a}::${b}`, + { normalizer: ([a, b = ""]) => `${a}::${b}` }, ) export function getProvider(network: Network): RpcProvider { @@ -34,26 +22,3 @@ export function getProvider(network: Network): RpcProvider { ) as constants.StarknetChainId return getProviderForRpcUrl(network.rpcUrl, chainId) } - -/** - * Returns a provider for the given network - * @param network - * @returns - */ -export function getProvider5(network: Network): RpcProvider5 { - const chainId = shortString.encodeShortString( - network.chainId, - ) as constants.StarknetChainId - return getProviderForRpcUrl5(network.rpcUrl, chainId) -} - -/** ======================================================================== */ - -export function getProviderv4(network: Network): RpcProviderV4 { - return new RpcProviderV4({ - nodeUrl: network.rpcUrl, - headers: argentXHeaders, - }) -} - -/** ======================================================================== */ diff --git a/packages/extension/src/shared/network/schema.ts b/packages/extension/src/shared/network/schema.ts index dd38d95f6..1a1d435c7 100644 --- a/packages/extension/src/shared/network/schema.ts +++ b/packages/extension/src/shared/network/schema.ts @@ -92,6 +92,12 @@ export const networkSchema = baseNetworkSchema.extend({ message: `Account class hash must match the following: /^0x[a-f0-9]+$/i`, }) .optional(), + imported: z + .string() + .regex(REGEX_HEXSTRING, { + message: `Account class hash must match the following: /^0x[a-f0-9]+$/i`, + }) + .optional(), }), z.undefined(), ]), diff --git a/packages/extension/src/shared/network/selectors.ts b/packages/extension/src/shared/network/selectors.ts index 75afeacc1..19f313c73 100644 --- a/packages/extension/src/shared/network/selectors.ts +++ b/packages/extension/src/shared/network/selectors.ts @@ -1,11 +1,13 @@ -import { memoize } from "lodash-es" +import memoize from "memoizee" -import { BaseNetwork, Network } from "./type" +import type { BaseNetwork, Network } from "./type" export const networkSelector = memoize( (networkId: string) => (network: BaseNetwork) => network.id === networkId, + { primitive: true }, ) export const networkSelectorByChainId = memoize( (chainId: string) => (network: Network) => network.chainId === chainId, + { primitive: true }, ) diff --git a/packages/extension/src/shared/network/service/INetworkService.ts b/packages/extension/src/shared/network/service/INetworkService.ts index a0b88a70d..e67ca77c3 100644 --- a/packages/extension/src/shared/network/service/INetworkService.ts +++ b/packages/extension/src/shared/network/service/INetworkService.ts @@ -1,5 +1,5 @@ -import { SelectorFn } from "../../storage/__new/interface" -import { Network } from "../type" +import type { SelectorFn } from "../../storage/__new/interface" +import type { Network } from "../type" export interface INetworkService { get(selector?: SelectorFn): Promise diff --git a/packages/extension/src/shared/network/service/NetworkService.ts b/packages/extension/src/shared/network/service/NetworkService.ts index c10cd4670..5dfff79d4 100644 --- a/packages/extension/src/shared/network/service/NetworkService.ts +++ b/packages/extension/src/shared/network/service/NetworkService.ts @@ -1,7 +1,7 @@ -import { SelectorFn } from "../../storage/__new/interface" +import type { SelectorFn } from "../../storage/__new/interface" import { networkSelector, networkSelectorByChainId } from "../selectors" -import { INetworkRepo } from "../store" -import { Network } from "../type" +import type { INetworkRepo } from "../store" +import type { Network } from "../type" import type { INetworkService } from "./INetworkService" import { defaultNetworks } from "../defaults" import { getDefaultNetwork } from "../utils" diff --git a/packages/extension/src/shared/network/type.ts b/packages/extension/src/shared/network/type.ts index 0a08eab14..958388556 100644 --- a/packages/extension/src/shared/network/type.ts +++ b/packages/extension/src/shared/network/type.ts @@ -1,7 +1,7 @@ import type { ArgentNetworkId, ArgentBackendNetworkId } from "@argent/x-shared" -import { z } from "zod" +import type { z } from "zod" -import { +import type { baseNetworkSchema, networkSchema, colorStatusSchema, diff --git a/packages/extension/src/shared/nft/INFTService.ts b/packages/extension/src/shared/nft/INFTService.ts index 11d886b6e..d46ccbefd 100644 --- a/packages/extension/src/shared/nft/INFTService.ts +++ b/packages/extension/src/shared/nft/INFTService.ts @@ -1,7 +1,7 @@ -import { Address, Collection, NftItem } from "@argent/x-shared" -import { ContractAddress } from "./store" -import { AllowArray } from "../storage/types" -import { Network } from "../network" +import type { Address, Collection, NftItem } from "@argent/x-shared" +import type { ContractAddress } from "./store" +import type { AllowArray } from "../storage/types" +import type { Network } from "../network" export interface INFTService { isSupported: (network: Network) => boolean diff --git a/packages/extension/src/shared/nft/NFTService.test.ts b/packages/extension/src/shared/nft/NFTService.test.ts index d2816e359..0ef56b4db 100644 --- a/packages/extension/src/shared/nft/NFTService.test.ts +++ b/packages/extension/src/shared/nft/NFTService.test.ts @@ -1,7 +1,8 @@ -import { BackendNftService } from "@argent/x-shared" +import type { BackendNftService } from "@argent/x-shared" import { http, HttpResponse } from "msw" import { setupServer } from "msw/node" -import { beforeEach, describe, expect, vi, Mocked } from "vitest" +import type { Mocked } from "vitest" +import { beforeEach, describe, expect, vi } from "vitest" import { NFTService } from "./NFTService" import { emptyJson, @@ -11,12 +12,12 @@ import { validJson, } from "./__mocks__/nft.mock" import { constants } from "starknet" -import { +import type { nftsCollectionsRepository, nftsContractsRepository, nftsRepository, } from "./store" -import { networkService } from "../network/service" +import type { networkService } from "../network/service" import type { KeyValueStorage } from "../storage" import type { ISettingsStorage } from "../settings/types" diff --git a/packages/extension/src/shared/nft/NFTService.ts b/packages/extension/src/shared/nft/NFTService.ts index d2eb53f22..5df1a12b0 100644 --- a/packages/extension/src/shared/nft/NFTService.ts +++ b/packages/extension/src/shared/nft/NFTService.ts @@ -1,16 +1,16 @@ +import type { BackendNftService, PaginatedCollections } from "@argent/x-shared" import { type Address, type ArgentBackendNetworkId, - BackendNftService, type Collection, type NftItem, type PaginatedItems, isEqualAddress, isArgentNetworkId, - PaginatedCollections, } from "@argent/x-shared" import { differenceWith, groupBy, isEqual } from "lodash-es" -import { AllowArray, constants, num, shortString } from "starknet" +import type { AllowArray } from "starknet" +import { constants, num, shortString } from "starknet" import type { INFTService } from "./INFTService" import type { ContractAddress, @@ -18,8 +18,8 @@ import type { INftsContractsRepository, INftsRepository, } from "./store" -import { Network } from "../network" -import { NetworkService } from "../network/service/NetworkService" +import type { Network } from "../network" +import type { NetworkService } from "../network/service/NetworkService" import type { KeyValueStorage } from "../storage" import type { ISettingsStorage } from "../settings/types" import { diff --git a/packages/extension/src/shared/nft/index.ts b/packages/extension/src/shared/nft/index.ts index a3c931e9d..ba8624f60 100644 --- a/packages/extension/src/shared/nft/index.ts +++ b/packages/extension/src/shared/nft/index.ts @@ -6,18 +6,13 @@ import { nftsRepository, } from "./store" import { NFTService } from "./NFTService" -import { ARGENT_API_BASE_URL, ARGENT_OPTIMIZER_URL } from "../api/constants" +import { ARGENT_API_BASE_URL } from "../api/constants" import { settingsStore } from "../settings/store" +import { httpService } from "../http/singleton" export const backendNftService = new BackendNftService( ARGENT_API_BASE_URL, - { - headers: { - "argent-version": process.env.VERSION ?? "Unknown version", - "argent-client": "argent-x", - }, - }, - ARGENT_OPTIMIZER_URL, + httpService, ) export const nftService = new NFTService( diff --git a/packages/extension/src/shared/nft/marketplaces/ekuboMarketplace.test.ts b/packages/extension/src/shared/nft/marketplaces/ekuboMarketplace.test.ts index c5505fe74..c1eaedb4d 100644 --- a/packages/extension/src/shared/nft/marketplaces/ekuboMarketplace.test.ts +++ b/packages/extension/src/shared/nft/marketplaces/ekuboMarketplace.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "vitest" import { isEkuboNft } from "./ekuboMarketplace" -import { NftItem } from "@argent/x-shared" +import type { NftItem } from "@argent/x-shared" const mockEkuboNft: Partial = { token_id: "231988", diff --git a/packages/extension/src/shared/nft/marketplaces/ekuboMarketplace.ts b/packages/extension/src/shared/nft/marketplaces/ekuboMarketplace.ts index 709012f04..c3ea16e94 100644 --- a/packages/extension/src/shared/nft/marketplaces/ekuboMarketplace.ts +++ b/packages/extension/src/shared/nft/marketplaces/ekuboMarketplace.ts @@ -1,8 +1,9 @@ -import { Address, NftItem, isEqualAddress } from "@argent/x-shared" +import type { Address, NftItem } from "@argent/x-shared" +import { isEqualAddress } from "@argent/x-shared" import { constants } from "starknet" import { z } from "zod" -import { NftMarketplace } from "./types" +import type { NftMarketplace } from "./types" export const ekuboNftContract: Address = `0x07b696af58c967c1b14c9dde0ace001720635a660a8e90c565ea459345318b30` export const ekuboPositionsContract: Address = `0x02e0af29598b407c8716b17f6d2795eca1b471413fa03fb145a5e33722184067` diff --git a/packages/extension/src/shared/nft/marketplaces/index.ts b/packages/extension/src/shared/nft/marketplaces/index.ts index 04e3e6931..d9fc03931 100644 --- a/packages/extension/src/shared/nft/marketplaces/index.ts +++ b/packages/extension/src/shared/nft/marketplaces/index.ts @@ -1,3 +1,3 @@ export * from "./defaultNftMarketplaces" export * from "./ekuboMarketplace" -export * from "./types" +export type * from "./types" diff --git a/packages/extension/src/shared/nft/marketplaces/types.ts b/packages/extension/src/shared/nft/marketplaces/types.ts index da7346c88..7998772ea 100644 --- a/packages/extension/src/shared/nft/marketplaces/types.ts +++ b/packages/extension/src/shared/nft/marketplaces/types.ts @@ -3,7 +3,7 @@ import type { Address } from "@argent/x-shared" // eslint-disable-next-line @argent/local/code-import-patterns import type { LogoDeprecatedKeys } from "@argent/x-ui" -import { defaultNftMarketplaces } from "./defaultNftMarketplaces" +import type { defaultNftMarketplaces } from "./defaultNftMarketplaces" export type NftMarketplaceKey = keyof typeof defaultNftMarketplaces diff --git a/packages/extension/src/shared/nft/store.ts b/packages/extension/src/shared/nft/store.ts index 556bd9cd4..6079dbf8a 100644 --- a/packages/extension/src/shared/nft/store.ts +++ b/packages/extension/src/shared/nft/store.ts @@ -1,4 +1,5 @@ -import { Collection, NftItem, isEqualAddress } from "@argent/x-shared" +import type { Collection, NftItem } from "@argent/x-shared" +import { isEqualAddress } from "@argent/x-shared" import browser from "webextension-polyfill" import { ChromeRepository } from "../storage/__new/chrome" diff --git a/packages/extension/src/shared/onRamp/IOnRampService.ts b/packages/extension/src/shared/onRamp/IOnRampService.ts index af2588b22..73eaf820d 100644 --- a/packages/extension/src/shared/onRamp/IOnRampService.ts +++ b/packages/extension/src/shared/onRamp/IOnRampService.ts @@ -1,4 +1,4 @@ -import { Address } from "@argent/x-shared" +import type { Address } from "@argent/x-shared" export interface IOnRampService { /** get topper argent url */ diff --git a/packages/extension/src/shared/preAuthorization/PreAuthorizationService.test.ts b/packages/extension/src/shared/preAuthorization/PreAuthorizationService.test.ts index 635dc3f26..251ecd651 100644 --- a/packages/extension/src/shared/preAuthorization/PreAuthorizationService.test.ts +++ b/packages/extension/src/shared/preAuthorization/PreAuthorizationService.test.ts @@ -3,11 +3,13 @@ import { describe, expect, test } from "vitest" import { InMemoryRepository } from "../storage/__new/__test__/inmemoryImplementations" import { isEqualPreAuthorization, type PreAuthorization } from "./schema" import { PreAuthorizationService } from "./PreAuthorizationService" +import { getRandomAccountIdentifier } from "../utils/accountIdentifier" describe("PreAuthorisationService", () => { const preAuthorizations: PreAuthorization[] = [ { account: { + id: getRandomAccountIdentifier(), address: "0x123", networkId: "foo", }, @@ -15,6 +17,7 @@ describe("PreAuthorisationService", () => { }, { account: { + id: getRandomAccountIdentifier(), address: "0x123", networkId: "bar", }, @@ -63,6 +66,7 @@ describe("PreAuthorisationService", () => { await expect( preAuthorisationService.add({ account: { + id: "id1", address: "0x123", networkId: "foo", }, diff --git a/packages/extension/src/shared/recovery/storage.ts b/packages/extension/src/shared/recovery/storage.ts index edcda4541..5d9bf9402 100644 --- a/packages/extension/src/shared/recovery/storage.ts +++ b/packages/extension/src/shared/recovery/storage.ts @@ -1,6 +1,6 @@ import { KeyValueStorage } from "../storage" import { adaptKeyValue } from "../storage/__new/keyvalue" -import { IRecoveryStorage } from "./types" +import type { IRecoveryStorage } from "./types" const keyValueStorage = new KeyValueStorage( { diff --git a/packages/extension/src/shared/riskAssessment/IRiskAssessmentService.ts b/packages/extension/src/shared/riskAssessment/IRiskAssessmentService.ts index 605f55cbf..9b752e273 100644 --- a/packages/extension/src/shared/riskAssessment/IRiskAssessmentService.ts +++ b/packages/extension/src/shared/riskAssessment/IRiskAssessmentService.ts @@ -1,5 +1,5 @@ import { z } from "zod" -import { RiskAssessment } from "./schema" +import type { RiskAssessment } from "./schema" export const dappContextSchema = z.object({ dappDomain: z.string(), diff --git a/packages/extension/src/shared/schedule/ChromeScheduleService.test.ts b/packages/extension/src/shared/schedule/ChromeScheduleService.test.ts index af06626c7..9e50b7fa8 100644 --- a/packages/extension/src/shared/schedule/ChromeScheduleService.test.ts +++ b/packages/extension/src/shared/schedule/ChromeScheduleService.test.ts @@ -1,7 +1,10 @@ import { beforeEach, describe, test, vi } from "vitest" import { ChromeScheduleService } from "./ChromeScheduleService" -import { BaseScheduledTask, ImplementedScheduledTask } from "./IScheduleService" +import type { + BaseScheduledTask, + ImplementedScheduledTask, +} from "./IScheduleService" function getMockBrowser() { const onStartUpListeners: Array<(...args: unknown[]) => void> = [] diff --git a/packages/extension/src/shared/schedule/ChromeScheduleService.ts b/packages/extension/src/shared/schedule/ChromeScheduleService.ts index e4d77eb67..24ec4cb85 100644 --- a/packages/extension/src/shared/schedule/ChromeScheduleService.ts +++ b/packages/extension/src/shared/schedule/ChromeScheduleService.ts @@ -1,6 +1,6 @@ -import { DeepPick } from "../types/deepPick" +import type { DeepPick } from "../types/deepPick" import { ALARM_VERSION } from "./constants" -import { +import type { BaseScheduledTask, IScheduleService, ImplementedScheduledTask, diff --git a/packages/extension/src/shared/schedule/mock.ts b/packages/extension/src/shared/schedule/mock.ts index 82b98f424..f5f393688 100644 --- a/packages/extension/src/shared/schedule/mock.ts +++ b/packages/extension/src/shared/schedule/mock.ts @@ -1,4 +1,7 @@ -import { IScheduleService, ImplementedScheduledTask } from "./IScheduleService" +import type { + IScheduleService, + ImplementedScheduledTask, +} from "./IScheduleService" interface ScheduleServiceManager { fireAll: ( diff --git a/packages/extension/src/shared/send/schema.test.ts b/packages/extension/src/shared/send/schema.test.ts deleted file mode 100644 index 8ae0b3fdb..000000000 --- a/packages/extension/src/shared/send/schema.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { describe, expect, test } from "vitest" - -import { parseQuery, sendQuerySchema } from "./schema" -import { stark } from "starknet" -import { addressSchema } from "@argent/x-shared" - -const mockTokenAddress = addressSchema.parse(stark.randomAddress()) - -describe("send", () => { - describe("schema", () => { - describe("when valid", () => { - test("should extract the values", () => { - expect( - parseQuery(new URLSearchParams(), sendQuerySchema), - ).toMatchInlineSnapshot("{}") - expect( - parseQuery( - new URLSearchParams({ - tokenId: "123", - tokenAddress: mockTokenAddress, - }), - sendQuerySchema, - ), - ).toEqual({ - tokenId: "123", - tokenAddress: mockTokenAddress, - }) - }) - }) - describe("when invalid", () => { - test("should ignore invalid values", () => { - expect( - parseQuery(new URLSearchParams({ foo: "bar" }), sendQuerySchema), - ).toMatchInlineSnapshot("{}") - expect( - parseQuery( - new URLSearchParams({ - foo: "bar", - tokenId: "123", - tokenAddress: mockTokenAddress, - }), - sendQuerySchema, - ), - ).toEqual({ - tokenId: "123", - tokenAddress: mockTokenAddress, - }) - }) - }) - }) -}) diff --git a/packages/extension/src/shared/send/schema.ts b/packages/extension/src/shared/send/schema.ts index 93c528bbc..789d13e7e 100644 --- a/packages/extension/src/shared/send/schema.ts +++ b/packages/extension/src/shared/send/schema.ts @@ -14,17 +14,3 @@ export type SendQuery = z.infer export const isSendQuery = (query: any): query is SendQuery => { return sendQuerySchema.safeParse(query).success } - -/** TODO: refactor - make into a generic helper */ - -export const parseQuery = ( - query: URLSearchParams, - schema: T, -): z.output => { - const unknownValues: Record = {} - for (const [key, value] of query.entries()) { - unknownValues[key] = value - } - const result = schema.safeParse(unknownValues) - return result.success ? result.data : {} -} diff --git a/packages/extension/src/shared/sentry/types.ts b/packages/extension/src/shared/sentry/types.ts index 4d0caa9e9..56f70a4f4 100644 --- a/packages/extension/src/shared/sentry/types.ts +++ b/packages/extension/src/shared/sentry/types.ts @@ -1,4 +1,4 @@ -import { BrowserClient } from "@sentry/browser" +import type { BrowserClient } from "@sentry/browser" /** type not exported by Sentry */ export type BrowserClientOptions = ConstructorParameters< diff --git a/packages/extension/src/shared/sessionKeys/schema.ts b/packages/extension/src/shared/sessionKeys/schema.ts index 19545af1c..8681cac4c 100644 --- a/packages/extension/src/shared/sessionKeys/schema.ts +++ b/packages/extension/src/shared/sessionKeys/schema.ts @@ -27,7 +27,7 @@ export const sessionKeyMetadataSchema = z try { const json = JSON.parse(str) return sessionKeyMetadataJsonSchema.parse(json) - } catch (e) { + } catch { ctx.addIssue({ code: "custom", message: "Invalid Metadata" }) return z.NEVER } diff --git a/packages/extension/src/shared/sessionKeys/whitelist.ts b/packages/extension/src/shared/sessionKeys/whitelist.ts index 17aaf1e99..3d1cfd9ca 100644 --- a/packages/extension/src/shared/sessionKeys/whitelist.ts +++ b/packages/extension/src/shared/sessionKeys/whitelist.ts @@ -1,15 +1,6 @@ -export const sessionKeysWhitelistedDomains = [ - "http://localhost:3000", - // argent development demo dapp - "https://dapp-ruby.vercel.app", // hydrogen - "https://dapp-argentlabs-staging.vercel.app", // staging - "https://dapp-argentlabs.vercel.app", // production +export const influenceWhitelistedDomains = [ "https://game.influenceth.io", "https://game-prerelease.influenceth.io", "https://assets.influenceth.io", "https://assets-prerelease.influenceth.io", ] - -export const isSessionKeysWhitelistedDomain = (domain?: string) => { - return domain && sessionKeysWhitelistedDomains.includes(domain) -} diff --git a/packages/extension/src/shared/settings/store.ts b/packages/extension/src/shared/settings/store.ts index 0b19a1290..af39d2334 100644 --- a/packages/extension/src/shared/settings/store.ts +++ b/packages/extension/src/shared/settings/store.ts @@ -1,4 +1,5 @@ import { KeyValueStorage } from "../storage" +import { adaptKeyValue } from "../storage/__new/keyvalue" import { defaultAutoLockTimeMinutes } from "./defaultAutoLockTimes" import { defaultBlockExplorerKey } from "./defaultBlockExplorers" import type { ISettingsStorage } from "./types" @@ -14,8 +15,10 @@ export const settingsStore = new KeyValueStorage( nftMarketplaceKey: "unframed", autoLockTimeMinutes: defaultAutoLockTimeMinutes, disableAnimation: false, - hideSpamTokens: true, airGapEnabled: false, + idProvider: "starknetid", }, "core:settings", ) + +export const settingsObjectStore = adaptKeyValue(settingsStore) diff --git a/packages/extension/src/shared/settings/types.ts b/packages/extension/src/shared/settings/types.ts index 5078a5d41..c68f5055e 100644 --- a/packages/extension/src/shared/settings/types.ts +++ b/packages/extension/src/shared/settings/types.ts @@ -11,8 +11,8 @@ export interface ISettingsStorage { nftMarketplaceKey: NftMarketplaceKey autoLockTimeMinutes: number disableAnimation: boolean - hideSpamTokens: boolean airGapEnabled: boolean + idProvider: "starknetid" | "brotherid" } export type SettingsStorageKey = keyof ISettingsStorage diff --git a/packages/extension/src/shared/signer/ArgentSigner.ts b/packages/extension/src/shared/signer/ArgentSigner.ts index 778def825..f0e5e99d7 100644 --- a/packages/extension/src/shared/signer/ArgentSigner.ts +++ b/packages/extension/src/shared/signer/ArgentSigner.ts @@ -1,15 +1,17 @@ -import { Signature, Signer, encode, num } from "starknet" +import type { Signature } from "starknet" +import { Signer, encode, num } from "starknet" import { HDKey } from "@scure/bip32" import { hexToBytes } from "@noble/curves/abstract/utils" import { grindKey } from "./utils" -import { BaseSignerInterface } from "./BaseSignerInterface" +import type { BaseSignerInterface } from "./BaseSignerInterface" import { SignerType } from "../wallet.model" -import { PublicKeyWithIndex } from "./types" +import type { PublicKeyWithIndex } from "./types" import { isString } from "lodash-es" import { getStarkKey } from "micro-starknet" export class ArgentSigner extends Signer implements BaseSignerInterface { signerType: SignerType + derivationPath: string constructor(secret: string, derivationPath: string) { const hex = encode.removeHexPrefix(num.toHex(secret)) // Bytes must be a multiple of 2 and default is multiple of 8 @@ -25,6 +27,7 @@ export class ArgentSigner extends Signer implements BaseSignerInterface { const groundKey = grindKey(childNode.privateKey) super(encode.sanitizeHex(groundKey)) this.signerType = SignerType.LOCAL_SECRET + this.derivationPath = derivationPath } async getPubKey(): Promise { diff --git a/packages/extension/src/shared/signer/BaseSignerInterface.ts b/packages/extension/src/shared/signer/BaseSignerInterface.ts index 1fb5e290e..286ac1c7a 100644 --- a/packages/extension/src/shared/signer/BaseSignerInterface.ts +++ b/packages/extension/src/shared/signer/BaseSignerInterface.ts @@ -1,4 +1,5 @@ -import { Signature, SignerInterface } from "starknet" +import type { Signature } from "starknet" +import { SignerInterface } from "starknet" export abstract class BaseSignerInterface extends SignerInterface { abstract signerType: string diff --git a/packages/extension/src/shared/signer/GuardianSignerV2.ts b/packages/extension/src/shared/signer/GuardianSignerV2.ts index 6d732c374..c42ecddce 100644 --- a/packages/extension/src/shared/signer/GuardianSignerV2.ts +++ b/packages/extension/src/shared/signer/GuardianSignerV2.ts @@ -1,30 +1,22 @@ -import { +import type { Abi, Call, - CallData, DeclareSignerDetails, DeployAccountSignerDetails, InvocationsSignerDetails, Signature, TypedData, - hash, - stark, -} from "starknet" -import { - addAddressPadding, - num, - transaction, - CairoVersion, - V3InvocationsSignerDetails, } from "starknet" +import { CallData, hash, stark } from "starknet" +import type { CairoVersion, V3InvocationsSignerDetails } from "starknet" +import { addAddressPadding, num, transaction } from "starknet" import type { Cosigner, CosignerMessage, CosignerOffchainMessage, } from "@argent/x-guardian" -import { BaseSignerInterface } from "./BaseSignerInterface" +import type { BaseSignerInterface } from "./BaseSignerInterface" import { isEqualAddress } from "@argent/x-shared" -import { isTokenExpired } from "../smartAccount/backend/account" export function isV3Details( details: T, @@ -119,12 +111,6 @@ export class GuardianSignerV2 implements BaseSignerInterface { return [] } - const tokenExpired = await isTokenExpired() - - if (tokenExpired) { - throw new Error("Smart Account token is expired") - } - const response = await this.cosigner(cosignerMessage, isOffchainMessage) return [ diff --git a/packages/extension/src/shared/signer/LedgerSigner.ts b/packages/extension/src/shared/signer/LedgerSigner.ts index 00660d691..7a04e0c22 100644 --- a/packages/extension/src/shared/signer/LedgerSigner.ts +++ b/packages/extension/src/shared/signer/LedgerSigner.ts @@ -1,36 +1,39 @@ -import { +import type { ArraySignatureType, Call, - CallData, Calldata, DeclareSignerDetails, DeployAccountSignerDetails, InvocationsSignerDetails, - RPC, Signature, TypedData, V2DeployAccountSignerDetails, V2InvocationsSignerDetails, V3DeployAccountSignerDetails, V3InvocationsSignerDetails, - encode, - hash, - stark, - transaction, - typedData, } from "starknet" -import { LedgerError, StarknetClient } from "@ledgerhq/hw-app-starknet" -import { BaseSignerInterface } from "./BaseSignerInterface" +import { CallData, RPC, constants, encode, hash, stark } from "starknet" +import type { + DeployAccountFields, + DeployAccountV1Fields, + ResponseTxSign, + StarknetClient, + TxFields, + TxV1Fields, +} from "@ledgerhq/hw-app-starknet" +import { LedgerError } from "@ledgerhq/hw-app-starknet" +import type { BaseSignerInterface } from "./BaseSignerInterface" import { SignerType } from "../wallet.model" -import { PublicKeyWithIndex } from "./types" +import type { PublicKeyWithIndex } from "./types" import { AxLedgerError } from "../errors/ledger" -import { LedgerSharedService } from "../ledger/service/LedgerSharedService" +import type { LedgerSharedService } from "../ledger/service/LedgerSharedService" +import semver from "semver" export class LedgerSigner implements BaseSignerInterface { signerType: SignerType constructor( public ledgerService: LedgerSharedService, - public derivatePath: string, + public derivationPath: string, ) { this.signerType = SignerType.LEDGER } @@ -39,7 +42,7 @@ export class LedgerSigner implements BaseSignerInterface { try { const app = await this.ledgerService.makeApp() const { starkKey, returnCode } = await app.getStarkKey( - this.derivatePath, + this.derivationPath, false, ) if (returnCode !== LedgerError.NoError) { @@ -55,47 +58,98 @@ export class LedgerSigner implements BaseSignerInterface { data: TypedData, accountAddress: string, ): Promise { - const msgHash = typedData.getMessageHash(data, accountAddress) - return this.signRawMsgHash(msgHash) + try { + const ledger = await this.ledgerService.makeApp() + await this.verifyAppVersion(ledger) + + const { r, s, returnCode } = await ledger.signMessage( + this.derivationPath, + data, + accountAddress, + ) + + if (returnCode !== LedgerError.NoError) { + throw new AxLedgerError({ code: returnCode }) + } + + return [r, s].map(this.makeHexFromBytes) + } finally { + void this.ledgerService.deinitApp() + } } async signTransaction( transactions: Call[], details: InvocationsSignerDetails, ): Promise { - const compiledCalldata = transaction.getExecuteCalldata( - transactions, - details.cairoVersion, - ) - const txHash = this.getInvokeTransactionHash(details, compiledCalldata) - return this.signRawMsgHash(txHash) + try { + const ledger = await this.ledgerService.makeApp() + await this.verifyAppVersion(ledger) + + let response: ResponseTxSign + + if (this.isV3InvokeSignerDetails(details)) { + const txFields: TxFields = { + ...details, + accountAddress: details.walletAddress, + paymaster_data: details.paymasterData, + account_deployment_data: details.accountDeploymentData, + } + + response = await ledger.signTx( + this.derivationPath, + transactions, + txFields, + ) + } else if (this.isV1SignerDetails(details)) { + const txFields: TxV1Fields = { + ...details, + accountAddress: details.walletAddress, + max_fee: details.maxFee, + } + + response = await ledger.signTxV1( + this.derivationPath, + transactions, + txFields, + ) + } else { + throw new Error("unsupported signTransaction version") + } + + const { r, s, returnCode } = response + + if (returnCode !== LedgerError.NoError) { + throw new AxLedgerError({ code: returnCode }) + } + + return [r, s].map(this.makeHexFromBytes) + } finally { + void this.ledgerService.deinitApp() + } } getInvokeTransactionHash( details: InvocationsSignerDetails, compiledCalldata: Calldata, ): string { - if ( - Object.values(RPC.ETransactionVersion2).includes(details.version as any) - ) { - const det = details as V2InvocationsSignerDetails + if (this.isV1SignerDetails(details)) { return hash.calculateInvokeTransactionHash({ - ...det, - senderAddress: det.walletAddress, + ...details, + senderAddress: details.walletAddress, compiledCalldata, - version: det.version, + version: details.version, }) - } else if ( - Object.values(RPC.ETransactionVersion3).includes(details.version as any) - ) { - const det = details as V3InvocationsSignerDetails + } else if (this.isV3InvokeSignerDetails(details)) { return hash.calculateInvokeTransactionHash({ - ...det, - senderAddress: det.walletAddress, + ...details, + senderAddress: details.walletAddress, compiledCalldata, - version: det.version, - nonceDataAvailabilityMode: stark.intDAM(det.nonceDataAvailabilityMode), - feeDataAvailabilityMode: stark.intDAM(det.feeDataAvailabilityMode), + version: details.version, + nonceDataAvailabilityMode: stark.intDAM( + details.nonceDataAvailabilityMode, + ), + feeDataAvailabilityMode: stark.intDAM(details.feeDataAvailabilityMode), }) } @@ -105,15 +159,55 @@ export class LedgerSigner implements BaseSignerInterface { async signDeployAccountTransaction( details: DeployAccountSignerDetails, ): Promise { - const compiledConstructorCalldata = CallData.compile( - details.constructorCalldata, - ) - const txHash = this.getDeployAccountTransactionHash( - details, - compiledConstructorCalldata, - ) + try { + const ledger = await this.ledgerService.makeApp() + await this.verifyAppVersion(ledger) + + let response: ResponseTxSign - return this.signRawMsgHash(txHash) + if (Object.values(RPC.ETransactionVersion3).includes(details.version)) { + const det = details as V3DeployAccountSignerDetails + + const txFields: DeployAccountFields = { + ...det, + paymaster_data: det.paymasterData, + constructor_calldata: CallData.toCalldata(det.constructorCalldata), + contract_address_salt: det.addressSalt.toString(), + class_hash: det.classHash, + } + + response = await ledger.signDeployAccount(this.derivationPath, txFields) + } else if ( + Object.values(RPC.ETransactionVersion2).includes(details.version) + ) { + const det = details as V2DeployAccountSignerDetails + + const txFields: DeployAccountV1Fields = { + ...det, + max_fee: det.maxFee, + contract_address_salt: det.addressSalt.toString(), + class_hash: det.classHash, + constructor_calldata: CallData.toCalldata(det.constructorCalldata), + } + + response = await ledger.signDeployAccountV1( + this.derivationPath, + txFields, + ) + } else { + throw new Error("unsupported signTransaction version") + } + + const { r, s, returnCode } = response + + if (returnCode !== LedgerError.NoError) { + throw new AxLedgerError({ code: returnCode }) + } + + return [r, s].map(this.makeHexFromBytes) + } finally { + void this.ledgerService.deinitApp() + } } getDeployAccountTransactionHash( @@ -153,8 +247,10 @@ export class LedgerSigner implements BaseSignerInterface { async signRawMsgHash(msgHash: string): Promise { try { const ledger = await this.ledgerService.makeApp() + await this.verifyAppVersion(ledger) + const { r, s, returnCode } = await ledger.signHash( - this.derivatePath, + this.derivationPath, msgHash, ) @@ -207,4 +303,32 @@ export class LedgerSigner implements BaseSignerInterface { return pubKeys } + + private async verifyAppVersion(ledger: StarknetClient) { + const { major, minor, patch } = await ledger.getAppVersion() + const currentAppVersion = `${major}.${minor}.${patch}` + const minAppVersion = process.env.MIN_LEDGER_APP_VERSION + + if (minAppVersion && semver.lt(currentAppVersion, minAppVersion)) { + throw new AxLedgerError({ code: "UNSUPPORTED_APP_VERSION" }) + } + } + + private isV3InvokeSignerDetails( + details: InvocationsSignerDetails, + ): details is V3InvocationsSignerDetails { + return [ + constants.TRANSACTION_VERSION.F3, + constants.TRANSACTION_VERSION.V3, + ].includes(details.version as any) + } + + private isV1SignerDetails( + details: InvocationsSignerDetails, + ): details is V2InvocationsSignerDetails { + return [ + constants.TRANSACTION_VERSION.F1, + constants.TRANSACTION_VERSION.V1, + ].includes(details.version as any) + } } diff --git a/packages/extension/src/shared/signer/PrivateKeySigner.ts b/packages/extension/src/shared/signer/PrivateKeySigner.ts new file mode 100644 index 000000000..2ae569b6c --- /dev/null +++ b/packages/extension/src/shared/signer/PrivateKeySigner.ts @@ -0,0 +1,44 @@ +import type { Signature } from "starknet" +import { encode, Signer } from "starknet" +import type { BaseSignerInterface } from "./BaseSignerInterface" +import { SignerType } from "../wallet.model" +import { getStarkKey } from "micro-starknet" +import { isString } from "lodash-es" + +export class PrivateKeySigner extends Signer implements BaseSignerInterface { + signerType: SignerType = SignerType.PRIVATE_KEY + + constructor(pk: string) { + super(pk) + } + + async getPubKey(): Promise { + return encode.sanitizeHex(await super.getPubKey()) + } + + /** + * Get the stark key of the signer + * This is same as getPublicKey, but it's not async + */ + getStarkKey(): string { + return encode.sanitizeHex(getStarkKey(this.pk)) + } + + getPrivateKey(): string { + const pk = encode.removeHexPrefix( + isString(this.pk) ? this.pk : encode.buf2hex(this.pk), + ) + const paddedPk = encode.padLeft(pk, 64) + return encode.addHexPrefix(paddedPk) + } + + signRawMsgHash(msgHash: string): Promise { + return this.signRaw(msgHash) + } + + public static isValid( + signer: BaseSignerInterface, + ): signer is PrivateKeySigner { + return signer.signerType === SignerType.PRIVATE_KEY + } +} diff --git a/packages/extension/src/shared/signer/derivationPaths.ts b/packages/extension/src/shared/signer/derivationPaths.ts index 926fe89b2..5646d8750 100644 --- a/packages/extension/src/shared/signer/derivationPaths.ts +++ b/packages/extension/src/shared/signer/derivationPaths.ts @@ -1,4 +1,8 @@ -import { CreateAccountType, SignerType } from "../wallet.model" +import type { + CreateAccountType, + ExternalAccountType, + SignerType, +} from "../wallet.model" export const STANDARD_ARGENT_DERIVATION_PATH = "m/44'/9004'/0'/0" export const MULTISIG_ARGENT_DERIVATION_PATH = "m/44'/9004'/1'/0" @@ -9,28 +13,41 @@ export const STANDARD_LEDGER_DERIVATION_PATH = export const MULTISIG_LEDGER_DERIVATION_PATH = "m/2645'/1195502025'/1148870696'/1'/0'" +export const DUMMY_PK_DERIVATION_PATH = "m/0/0/0/0" // This is a dummy derivation path + /** * * from https://community.starknet.io/t/account-keys-and-addresses-derivation-standard/1230 * m / purpose' / coin_type' / account' / change / address_index */ export const DERIVATION_PATHS: { - [key in CreateAccountType]: { [key2 in SignerType]: string | null } + [key in CreateAccountType | ExternalAccountType]: { + [key2 in SignerType]: string | null + } } = { standard: { local_secret: STANDARD_ARGENT_DERIVATION_PATH, ledger: STANDARD_LEDGER_DERIVATION_PATH, + private_key: null, }, multisig: { local_secret: MULTISIG_ARGENT_DERIVATION_PATH, ledger: MULTISIG_LEDGER_DERIVATION_PATH, + private_key: null, }, smart: { local_secret: STANDARD_ARGENT_DERIVATION_PATH, ledger: null, + private_key: null, }, standardCairo0: { local_secret: STANDARD_ARGENT_DERIVATION_PATH, ledger: null, + private_key: null, + }, + imported: { + local_secret: null, + ledger: null, + private_key: DUMMY_PK_DERIVATION_PATH, }, } diff --git a/packages/extension/src/shared/signer/utils.ts b/packages/extension/src/shared/signer/utils.ts index 570906820..e606e3dcb 100644 --- a/packages/extension/src/shared/signer/utils.ts +++ b/packages/extension/src/shared/signer/utils.ts @@ -1,9 +1,16 @@ -import { Hex, bytesToHex } from "@noble/curves/abstract/utils" +import type { Hex } from "@noble/curves/abstract/utils" +import { bytesToHex } from "@noble/curves/abstract/utils" import { sha256 } from "@noble/hashes/sha256" import { encode } from "starknet" import { grindKey as microGrindKey } from "micro-starknet" -import { CreateAccountType, SignerType } from "../wallet.model" +import type { + CreateAccountType, + ExternalAccountType, + SignerType, + WalletAccountType, +} from "../wallet.model" import { DERIVATION_PATHS } from "./derivationPaths" +import { assertNever } from "../utils/assertNever" const { addHexPrefix } = encode @@ -26,7 +33,7 @@ export function pathHash(name: string): number { } export function getBaseDerivationPath( - accountType: CreateAccountType, + accountType: CreateAccountType | ExternalAccountType, signerType: SignerType, ): string { const path = DERIVATION_PATHS[accountType][signerType] @@ -37,3 +44,35 @@ export function getBaseDerivationPath( } return path } + +export const getDerivationPathForIndex = ( + index: number, + signerType: SignerType, + accountType: WalletAccountType, +): string => { + const getDerivableType = ( + accountType: WalletAccountType, + ): CreateAccountType | ExternalAccountType => { + switch (accountType) { + case "standard": + case "multisig": + case "smart": + case "standardCairo0": + case "imported": + return accountType + case "argent5MinuteEscapeTestingAccount": + return "smart" + case "plugin": + case "betterMulticall": + return "standard" + default: + assertNever(accountType) + throw new Error(`Unsupported account type ${accountType}`) + } + } + + const derivableType = getDerivableType(accountType) + const baseDerivationPath = getBaseDerivationPath(derivableType, signerType) + + return `${baseDerivationPath}/${index}` +} diff --git a/packages/extension/src/shared/smartAccount/GuardianSelfSigner.ts b/packages/extension/src/shared/smartAccount/GuardianSelfSigner.ts index 9f5c4b6ff..b135ea7d8 100644 --- a/packages/extension/src/shared/smartAccount/GuardianSelfSigner.ts +++ b/packages/extension/src/shared/smartAccount/GuardianSelfSigner.ts @@ -1,12 +1,12 @@ -import { +import type { Call, DeclareSignerDetails, DeployAccountSignerDetails, InvocationsSignerDetails, Signature, - stark, } from "starknet" -import { TypedData } from "starknet" +import { stark } from "starknet" +import type { TypedData } from "starknet" import { ArgentSigner } from "../signer/ArgentSigner" /** diff --git a/packages/extension/src/shared/smartAccount/ISmartAccountService.ts b/packages/extension/src/shared/smartAccount/ISmartAccountService.ts index abe80288e..765c37da6 100644 --- a/packages/extension/src/shared/smartAccount/ISmartAccountService.ts +++ b/packages/extension/src/shared/smartAccount/ISmartAccountService.ts @@ -1,4 +1,4 @@ -import Emittery from "emittery" +import type Emittery from "emittery" export const IsSignedIn = Symbol("IsSignedIn") diff --git a/packages/extension/src/shared/smartAccount/SmartAccountService.ts b/packages/extension/src/shared/smartAccount/SmartAccountService.ts index c8f4c53b2..d23c158fb 100644 --- a/packages/extension/src/shared/smartAccount/SmartAccountService.ts +++ b/packages/extension/src/shared/smartAccount/SmartAccountService.ts @@ -1,14 +1,11 @@ import { liveQuery } from "dexie" import type { Device } from "@argent/x-guardian" -import Emittery from "emittery" +import type Emittery from "emittery" -import { StoreDexie } from "./idb" +import type { StoreDexie } from "./idb" import { isTokenExpired } from "./backend/account" -import { - Events, - IsSignedIn, - type ISmartAccountService, -} from "./ISmartAccountService" +import type { Events } from "./ISmartAccountService" +import { IsSignedIn, type ISmartAccountService } from "./ISmartAccountService" export default class SmartAccountService implements ISmartAccountService { private _isSignedIn: boolean | null = null diff --git a/packages/extension/src/shared/smartAccount/account.ts b/packages/extension/src/shared/smartAccount/account.ts index 28535e430..4a6078615 100644 --- a/packages/extension/src/shared/smartAccount/account.ts +++ b/packages/extension/src/shared/smartAccount/account.ts @@ -1,4 +1,4 @@ -import { +import type { Abi, AccountInterface, AllowArray, @@ -10,24 +10,22 @@ import { DeployContractResponse, ProviderInterface, Signature, - TransactionType, TypedData, UniversalDetails, +} from "starknet" +import { + TransactionType, constants, isSierra, provider, stark, transaction, } from "starknet" -import { BaseSignerInterface } from "../signer/BaseSignerInterface" -import { - Address, - addressSchema, - isEqualAddress, - txVersionSchema, -} from "@argent/x-shared" +import type { BaseSignerInterface } from "../signer/BaseSignerInterface" +import type { Address } from "@argent/x-shared" +import { addressSchema, isEqualAddress } from "@argent/x-shared" import { ArgentSigner, GuardianSignerV2 } from "../signer" -import { Cosigner } from "@argent/x-guardian" +import type { Cosigner } from "@argent/x-guardian" import { BaseStarknetAccount } from "../starknetAccount/base" import { isEmpty } from "lodash-es" @@ -104,7 +102,7 @@ export class SmartAccount extends BaseStarknetAccount { ? transactionsDetail : abiOrDetails const transactions = Array.isArray(calls) ? calls : [calls] - const version = txVersionSchema.parse(details.version) + const version = this.getTxVersion(details) const signerDetails = await this.buildInvocationSignerDetailsPayload(details) @@ -149,7 +147,7 @@ export class SmartAccount extends BaseStarknetAccount { payload: DeployAccountContractPayload, details: UniversalDetails = {}, ): Promise { - const version = txVersionSchema.parse(details.version) + const version = this.getTxVersion(details) const signerDetails = await this.buildAccountDeploySignerDetailsPayload( payload, details, @@ -191,7 +189,7 @@ export class SmartAccount extends BaseStarknetAccount { details: UniversalDetails = {}, ): Promise { const version = !isSierra(payload.contract) - ? txVersionSchema.parse(details.version) + ? this.getTxVersion(details) : constants.TRANSACTION_VERSION.V1 const signerDetails = await this.buildDeclareSignerDetailsPayload( diff --git a/packages/extension/src/shared/smartAccount/backend/account.ts b/packages/extension/src/shared/smartAccount/backend/account.ts index 28168cb4a..02db732f9 100644 --- a/packages/extension/src/shared/smartAccount/backend/account.ts +++ b/packages/extension/src/shared/smartAccount/backend/account.ts @@ -1,15 +1,11 @@ -import { +import type { Cosigner, CosignerMessage, CosignerOffchainMessage, CosignerResponse, } from "@argent/x-guardian" -import { - AddSmartAccountResponse, - AddSmartAcountRequestSchema, - BackendAccount, - BaseError, -} from "@argent/x-shared" +import type { AddSmartAccountResponse, BackendAccount } from "@argent/x-shared" +import { AddSmartAcountRequestSchema, BaseError } from "@argent/x-shared" import retry from "async-retry" import urlJoin from "url-join" import { z } from "zod" @@ -20,7 +16,6 @@ import { IS_DEV } from "../../utils/dev" import { coerceErrorToString } from "../../utils/error" import { idb } from "../idb" import { jwtFetcher } from "../jwtFetcher" -import { throttle } from "lodash-es" export const requestEmailAuthentication = async ( email: string, @@ -43,7 +38,7 @@ export const requestEmailAuthentication = async ( }, ) return json - } catch (error) { + } catch { throw new BaseError({ message: "failed to request email verification" }) } } @@ -81,7 +76,7 @@ export const getEmailVerificationStatus = async () => { urlJoin(ARGENT_API_BASE_URL, `account/emailVerificationStatus`), ) return json.status - } catch (error) { + } catch { throw new BaseError({ message: "Failed to get email verification status" }) } } @@ -134,7 +129,9 @@ export const register = async () => { return json } catch (error) { - IS_DEV && console.warn(coerceErrorToString(error)) + if (IS_DEV) { + console.warn(coerceErrorToString(error)) + } throw new BaseError({ message: "Failed to register" }) } } @@ -169,7 +166,7 @@ export const getRegistrationStatus = async () => { urlJoin(ARGENT_API_BASE_URL, `account/registrationStatus`), ) return json.status - } catch (error) { + } catch { throw new BaseError({ message: "Failed to get registration status" }) } } @@ -185,7 +182,9 @@ export const isTokenExpired = async () => { await idb.ids.put({ key: "userId", id: res.userId }) return false } catch (error) { - IS_DEV && console.warn(coerceErrorToString(error)) + if (IS_DEV) { + console.warn(coerceErrorToString(error)) + } } return true } @@ -203,11 +202,12 @@ export const getBackendAccounts = async () => { ), ) return json.accounts - } catch (error) { + } catch { throw new BaseError({ message: "Failed to get accounts" }) } } +// eslint-disable-next-line @typescript-eslint/no-unused-vars const AddBackendAccountSchema = AddSmartAcountRequestSchema.extend({ accountAddress: z.string().optional(), }) @@ -268,6 +268,11 @@ export const cosignerSign: Cosigner = async ( ) return json } catch (error) { + if (isFetcherError(error) && error.status === 403) { + throw new BaseError({ + message: "Smart Account token is expired", + }) + } throw new BaseError({ message: `This transaction failed as the cosigner could not provide a valid signature. Please contact support.`, }) diff --git a/packages/extension/src/shared/smartAccount/changeGuardianCallDataToType.ts b/packages/extension/src/shared/smartAccount/changeGuardianCallDataToType.ts index 2a5009a31..7b87d0c2f 100644 --- a/packages/extension/src/shared/smartAccount/changeGuardianCallDataToType.ts +++ b/packages/extension/src/shared/smartAccount/changeGuardianCallDataToType.ts @@ -1,11 +1,5 @@ -import { - CairoOptionVariant, - Call, - CallData, - Calldata, - constants, - num, -} from "starknet" +import type { Call, Calldata } from "starknet" +import { CairoOptionVariant, CallData, constants, num } from "starknet" export enum ChangeGuardian { /** Guardian is not being changed */ diff --git a/packages/extension/src/shared/smartAccount/getChangeGuardianCalldata.ts b/packages/extension/src/shared/smartAccount/getChangeGuardianCalldata.ts index 086f4cc9c..8aa7aa25c 100644 --- a/packages/extension/src/shared/smartAccount/getChangeGuardianCalldata.ts +++ b/packages/extension/src/shared/smartAccount/getChangeGuardianCalldata.ts @@ -13,7 +13,7 @@ import { num, } from "starknet" -import { WalletAccount } from "../wallet.model" +import type { WalletAccount } from "../wallet.model" export const getChangeGuardianCalldataForAccount = ({ account, diff --git a/packages/extension/src/shared/smartAccount/index.ts b/packages/extension/src/shared/smartAccount/index.ts index 88f267755..17e3e167a 100644 --- a/packages/extension/src/shared/smartAccount/index.ts +++ b/packages/extension/src/shared/smartAccount/index.ts @@ -1,6 +1,6 @@ import Emittery from "emittery" -import { Events } from "./ISmartAccountService" +import type { Events } from "./ISmartAccountService" import SmartAccountService from "./SmartAccountService" import { idb } from "./idb" diff --git a/packages/extension/src/shared/smartAccount/jwt.ts b/packages/extension/src/shared/smartAccount/jwt.ts index acf5909f7..19ce8c800 100644 --- a/packages/extension/src/shared/smartAccount/jwt.ts +++ b/packages/extension/src/shared/smartAccount/jwt.ts @@ -1,4 +1,4 @@ -import { Device } from "@argent/x-guardian" +import type { Device } from "@argent/x-guardian" import { SignJWT, calculateJwkThumbprint, diff --git a/packages/extension/src/shared/smartAccount/jwtFetcher.ts b/packages/extension/src/shared/smartAccount/jwtFetcher.ts index 403ddaae6..e36e02b79 100644 --- a/packages/extension/src/shared/smartAccount/jwtFetcher.ts +++ b/packages/extension/src/shared/smartAccount/jwtFetcher.ts @@ -22,7 +22,9 @@ export const jwtFetcher = async ( try { return await fetcher(input, initWithArgentJwtHeaders) } catch (error) { - IS_DEV && console.warn(coerceErrorToString(error)) + if (IS_DEV) { + console.warn(coerceErrorToString(error)) + } throw error } } diff --git a/packages/extension/src/shared/smartAccount/validation/addBackendAccount.ts b/packages/extension/src/shared/smartAccount/validation/addBackendAccount.ts index f23ea5e33..6d6ab83e3 100644 --- a/packages/extension/src/shared/smartAccount/validation/addBackendAccount.ts +++ b/packages/extension/src/shared/smartAccount/validation/addBackendAccount.ts @@ -1,18 +1,13 @@ -import { BaseError } from "@argent/x-shared" -import { isErrorOfType } from "../../errors/errorData" +import { getMessageFromTrpcError } from "@argent/x-shared" export const addBackendAccountErrorStatus = { accountAlreadyAdded: "This account is already added - please check account", accountInUse: "This account is already linked to a different email address", + ownerAlreadyInUse: "This email address is already in use", } as const export const getAddBackendAccountErrorFromBackendError = (error: unknown) => { - // Need BaseError.name instead of "BaseError" because the import from x-shared alters the name - if (!isErrorOfType(error, BaseError.name)) { - return null - } - - const message = error.data.message + const message = getMessageFromTrpcError(error) if (!message) { return null } diff --git a/packages/extension/src/shared/smartAccount/validation/validateAccount.test.ts b/packages/extension/src/shared/smartAccount/validation/validateAccount.test.ts index 5b51c1b4e..eef9cc594 100644 --- a/packages/extension/src/shared/smartAccount/validation/validateAccount.test.ts +++ b/packages/extension/src/shared/smartAccount/validation/validateAccount.test.ts @@ -1,4 +1,5 @@ -import { BackendAccount, BaseError } from "@argent/x-shared" +import type { BackendAccount } from "@argent/x-shared" +import { BaseError } from "@argent/x-shared" import { describe, expect, test } from "vitest" import { @@ -7,7 +8,7 @@ import { SMART_ACCOUNT_EMAIL_VALIDATION_FAILURE_SCENARIO_2, SMART_ACCOUNT_EMAIL_VALIDATION_FAILURE_SCENARIO_3, } from "../../errors/argentAccount" -import { WalletAccount } from "../../wallet.model" +import type { WalletAccount } from "../../wallet.model" import { getLocalAccountsMatchBackendAccounts, getSmartAccountValidationErrorFromBackendError, diff --git a/packages/extension/src/shared/smartAccount/validation/validateAccount.ts b/packages/extension/src/shared/smartAccount/validation/validateAccount.ts index bca4dd17c..0f5c29098 100644 --- a/packages/extension/src/shared/smartAccount/validation/validateAccount.ts +++ b/packages/extension/src/shared/smartAccount/validation/validateAccount.ts @@ -1,15 +1,15 @@ -import { BackendAccount } from "@argent/x-shared" +import type { BackendAccount } from "@argent/x-shared" import { num } from "starknet" +import type { SmartAccountValidationErrorMessage } from "../../errors/argentAccount" import { ArgentAccountError, SMART_ACCOUNT_EMAIL_VALIDATION_FAILURE_SCENARIO_1, SMART_ACCOUNT_EMAIL_VALIDATION_FAILURE_SCENARIO_2, SMART_ACCOUNT_EMAIL_VALIDATION_FAILURE_SCENARIO_3, - SmartAccountValidationErrorMessage, } from "../../errors/argentAccount" import { isErrorOfType } from "../../errors/errorData" -import { WalletAccount } from "../../wallet.model" +import type { WalletAccount } from "../../wallet.model" export const getSmartAccountValidationErrorFromBackendError = ( error: unknown, diff --git a/packages/extension/src/shared/staking/IStakingService.ts b/packages/extension/src/shared/staking/IStakingService.ts new file mode 100644 index 000000000..e23de80a9 --- /dev/null +++ b/packages/extension/src/shared/staking/IStakingService.ts @@ -0,0 +1,17 @@ +import type { StrkStakingCalldataWithAccountType } from "./types" +import type { + StrkStakingCalldata, + StrkStakingCalldataResponse, +} from "@argent/x-shared" + +export interface IStakingService { + getStakeCalldata: ( + input: StrkStakingCalldata, + ) => Promise + stake: (input: StrkStakingCalldataWithAccountType) => Promise + claim: (input: StrkStakingCalldataWithAccountType) => Promise + initiateUnstake: ( + input: StrkStakingCalldataWithAccountType, + ) => Promise + unstake: (input: StrkStakingCalldataWithAccountType) => Promise +} diff --git a/packages/extension/src/shared/staking/storage.ts b/packages/extension/src/shared/staking/storage.ts new file mode 100644 index 000000000..7139d0e79 --- /dev/null +++ b/packages/extension/src/shared/staking/storage.ts @@ -0,0 +1,14 @@ +import { KeyValueStorage } from "../storage" + +export interface IStakingStore { + enabled: boolean + apyPercentage: string +} + +export const stakingStore = new KeyValueStorage( + { + enabled: false, + apyPercentage: "0", + }, + "core:staking", +) diff --git a/packages/extension/src/shared/staking/types.ts b/packages/extension/src/shared/staking/types.ts new file mode 100644 index 000000000..6970f9baf --- /dev/null +++ b/packages/extension/src/shared/staking/types.ts @@ -0,0 +1,17 @@ +import { strkStakingCalldataSchema } from "@argent/x-shared" +import { walletAccountTypeSchema } from "../wallet.model" +import type { z } from "zod" + +export const strkStakingCalldataWithAccountTypeSchema = + strkStakingCalldataSchema.extend({ + accountType: walletAccountTypeSchema, + }) + +export type StrkStakingCalldataWithAccountType = z.infer< + typeof strkStakingCalldataWithAccountTypeSchema +> + +export interface BuildSellOpts { + useFullBalance?: boolean + subsequentTransaction?: boolean +} diff --git a/packages/extension/src/shared/staking/utils.ts b/packages/extension/src/shared/staking/utils.ts new file mode 100644 index 000000000..e948395a4 --- /dev/null +++ b/packages/extension/src/shared/staking/utils.ts @@ -0,0 +1,22 @@ +import { bigDecimal, DEFAULT_TOKEN_DECIMALS } from "@argent/x-shared" +import type { ParsedStrkDelegatedStakingPosition } from "../defiDecomposition/schema" +import { getActiveFromNow } from "../utils/getActiveFromNow" + +export function checkHasRewards( + balance: string, + decimals = DEFAULT_TOKEN_DECIMALS, // decimals don't matter when comparing to 0 +) { + return bigDecimal.gt( + bigDecimal.parseUnits(balance, decimals), + bigDecimal.parseUnits("0"), + ) +} + +export function isWithdrawAvailable({ + pendingWithdrawal, +}: Pick) { + return ( + !!pendingWithdrawal && // Check if there is a pending withdrawal + getActiveFromNow(pendingWithdrawal.withdrawableAfter).activeFromNowMs === 0 // Check if the withdrawal is available + ) +} diff --git a/packages/extension/src/shared/starknetAccount/base.ts b/packages/extension/src/shared/starknetAccount/base.ts index 4a107f546..b99d57e35 100644 --- a/packages/extension/src/shared/starknetAccount/base.ts +++ b/packages/extension/src/shared/starknetAccount/base.ts @@ -1,16 +1,18 @@ -import { - Account, +import type { ArraySignatureType, - CairoCustomEnum, CairoVersion, Call, - CallData, DeclareContractPayload, DeployAccountContractPayload, ProviderInterface, Signature, - TransactionType, UniversalDetails, +} from "starknet" +import { + Account, + CairoCustomEnum, + CallData, + TransactionType, constants, extractContractHashes, hash, @@ -18,13 +20,14 @@ import { stark, transaction, } from "starknet" -import { +import type { DeclareSignerBuilderPayload, DeployAccountSignerBuilderPayload, InvocationsSignerBuilderPayload, } from "./types" -import { Address, EDAMode } from "@starknet-io/types-js" -import { BaseSignerInterface } from "../signer/BaseSignerInterface" +import type { Address } from "@starknet-io/types-js" +import { EDAMode } from "@starknet-io/types-js" +import type { BaseSignerInterface } from "../signer/BaseSignerInterface" import { isEqualAddress, getArgentAccountWithMultiSignerClassHashes, @@ -121,11 +124,60 @@ export class BaseStarknetAccount extends Account { } } + public async buildAccountDeployTransactionPayload( + contractPayload: DeployAccountContractPayload, + details: UniversalDetails, + ) { + const version = this.getTxVersion(details) + + const payload = await this.buildAccountDeploySignerDetailsPayload( + contractPayload, + details, + ) + + const nonceDataAvailabilityMode = payload.nonceDataAvailabilityMode + ? stark.intDAM(payload.nonceDataAvailabilityMode) + : EDAMode.L1 + + const feeDataAvailabilityMode = payload.feeDataAvailabilityMode + ? stark.intDAM(payload.feeDataAvailabilityMode) + : EDAMode.L1 + + const estimate = await this.getUniversalSuggestedFee( + version, + { type: TransactionType.DEPLOY_ACCOUNT, payload }, + details, + ) + + return { + ...payload, + ...estimate, + nonceDataAvailabilityMode, + feeDataAvailabilityMode, + salt: payload.addressSalt, + compiledConstructorCalldata: CallData.compile( + payload.constructorCalldata, + ), + version: version as any, // TS, cast because version is issue in snjs + } + } + + public async getAccountDeployTransactionHash( + payload: DeployAccountContractPayload, + details: UniversalDetails = {}, + ) { + const transactionPayload = await this.buildAccountDeployTransactionPayload( + payload, + details, + ) + return hash.calculateDeployAccountTransactionHash(transactionPayload) + } + public async buildInvokeTransactionPayload( calls: Call | Call[], details: UniversalDetails = {}, ) { - const version = txVersionSchema.parse(details.version) + const version = this.getTxVersion(details) const { cairoVersion, walletAddress, ...payload } = await this.buildInvocationSignerDetailsPayload(details) @@ -193,4 +245,10 @@ export class BaseStarknetAccount extends Account { const compiledSigs = CallData.compile(args) return [signatureLength, ...compiledSigs] } + + protected getTxVersion({ + version, + }: Pick): constants.TRANSACTION_VERSION { + return txVersionSchema.parse(version) as constants.TRANSACTION_VERSION + } } diff --git a/packages/extension/src/shared/starknetAccount/index.ts b/packages/extension/src/shared/starknetAccount/index.ts index fc81f0e7d..746b90b7f 100644 --- a/packages/extension/src/shared/starknetAccount/index.ts +++ b/packages/extension/src/shared/starknetAccount/index.ts @@ -1,4 +1,4 @@ -import { +import type { Abi, AccountInterface, AllowArray, @@ -11,18 +11,21 @@ import { DeployContractResponse, ProviderInterface, Signature, - TransactionType, TypedData, UniversalDetails, +} from "starknet" +import { + TransactionType, constants, isSierra, provider, stark, transaction, } from "starknet" -import { BaseSignerInterface } from "../signer/BaseSignerInterface" +import type { BaseSignerInterface } from "../signer/BaseSignerInterface" import { ArgentSigner, LedgerSigner } from "../signer" -import { Address, addressSchema, txVersionSchema } from "@argent/x-shared" +import type { Address } from "@argent/x-shared" +import { addressSchema, txVersionSchema } from "@argent/x-shared" import { BaseStarknetAccount } from "./base" export class StarknetAccount extends BaseStarknetAccount { @@ -43,7 +46,9 @@ export class StarknetAccount extends BaseStarknetAccount { payload: DeployAccountContractPayload, details: UniversalDetails = {}, ): Promise { - const version = txVersionSchema.parse(details.version) + const version = txVersionSchema.parse( + details.version, + ) as constants.TRANSACTION_VERSION const signerDetails = await this.buildAccountDeploySignerDetailsPayload( payload, details, @@ -91,7 +96,7 @@ export class StarknetAccount extends BaseStarknetAccount { ? transactionsDetail : abiOrDetails const transactions = Array.isArray(calls) ? calls : [calls] - const version = txVersionSchema.parse(details.version) + const version = this.getTxVersion(details) const signerDetails = await this.buildInvocationSignerDetailsPayload(details) @@ -158,7 +163,7 @@ export class StarknetAccount extends BaseStarknetAccount { details: UniversalDetails = {}, ): Promise { const version = isSierra(payload.contract) // Means Cairo1 contract which supports txV2 and v3 - ? txVersionSchema.parse(details.version) + ? this.getTxVersion(details) : constants.TRANSACTION_VERSION.V1 // Cairo0 contract which only supports txV1 const signerDetails = await this.buildDeclareSignerDetailsPayload( diff --git a/packages/extension/src/shared/starknetAccount/types.ts b/packages/extension/src/shared/starknetAccount/types.ts index 4f060f72e..9e81ceaae 100644 --- a/packages/extension/src/shared/starknetAccount/types.ts +++ b/packages/extension/src/shared/starknetAccount/types.ts @@ -1,4 +1,4 @@ -import { +import type { DeclareSignerDetails, DeployAccountSignerDetails, InvocationsDetails, diff --git a/packages/extension/src/shared/statusMessage/storage.ts b/packages/extension/src/shared/statusMessage/storage.ts index c609c92ee..a13a6e9fb 100644 --- a/packages/extension/src/shared/statusMessage/storage.ts +++ b/packages/extension/src/shared/statusMessage/storage.ts @@ -1,5 +1,5 @@ import { KeyValueStorage } from "../storage" -import { IStatusMessageStorage } from "./types" +import type { IStatusMessageStorage } from "./types" export const statusMessageStore = new KeyValueStorage( {}, diff --git a/packages/extension/src/shared/statusMessage/types.ts b/packages/extension/src/shared/statusMessage/types.ts index 7c767f466..589107761 100644 --- a/packages/extension/src/shared/statusMessage/types.ts +++ b/packages/extension/src/shared/statusMessage/types.ts @@ -1,4 +1,4 @@ -export type IStatusMessageLevel = "info" | "warn" | "danger" | undefined +export type IStatusMessageLevel = "info" | "warning" | "danger" export interface IStatusMessage { /** the unique ID of this message */ diff --git a/packages/extension/src/shared/storage/__new/__test__/__fixtures__/storage.json b/packages/extension/src/shared/storage/__new/__test__/__fixtures__/storage.json new file mode 100644 index 000000000..3ca8d4c89 --- /dev/null +++ b/packages/extension/src/shared/storage/__new/__test__/__fixtures__/storage.json @@ -0,0 +1,5363 @@ +{ + "PRIVATE_KEY": "{\"alg\":\"ECDH-ES\",\"crv\":\"P-256\",\"d\":\"q7zl3beo3waEehTIfywDI6HKoUGtzPbVPTxjHVTcVZo\",\"kty\":\"EC\",\"x\":\"WVDeNMWCy-H1-uIzpaMVaDbM7yBtUzBInSUvZhwx_zY\",\"y\":\"ca2McsMJeNUAKTKBuHKIay80diHffGBNxjkyzNMWZd8\"}", + "PUBLIC_KEY": "{\"alg\":\"ECDH-ES\",\"crv\":\"P-256\",\"kty\":\"EC\",\"x\":\"WVDeNMWCy-H1-uIzpaMVaDbM7yBtUzBInSUvZhwx_zY\",\"y\":\"ca2McsMJeNUAKTKBuHKIay80diHffGBNxjkyzNMWZd8\"}", + "core:accounts:inner": [ + { + "address": "0x22923ab1d747a5f534a27c9ff7fd9ba3d7cd2d7142c46f7821dd07b9d0d1bc0", + "cairoVersion": "1", + "classHash": "0x036078334509b514626504edc9fb252328d1a240e4e948bef8d0c08dff45927f", + "id": "0x022923ab1d747a5f534a27c9ff7fd9ba3d7cd2d7142c46f7821dd07b9d0d1bc0::sepolia-alpha::local_secret::0", + "index": 0, + "name": "Account 1", + "needsDeploy": true, + "networkId": "sepolia-alpha", + "signer": { + "derivationPath": "m/44'/9004'/0'/0/0", + "type": "local_secret" + }, + "type": "standard" + }, + { + "address": "0x51cda0876cb58b05d9a6d9da52b00b8d04733028def56668867e3bffd9fac47", + "cairoVersion": "1", + "classHash": "0x06e150953b26271a740bf2b6e9bca17cc52c68d765f761295de51ceb8526ee72", + "id": "0x051cda0876cb58b05d9a6d9da52b00b8d04733028def56668867e3bffd9fac47::sepolia-alpha::ledger::43", + "index": 0, + "name": "Multisig 1", + "needsDeploy": false, + "networkId": "sepolia-alpha", + "signer": { + "derivationPath": "m/2645'/1195502025'/1148870696'/1'/0'/43", + "type": "ledger" + }, + "type": "multisig" + }, + { + "address": "0x1060ea20f2778571e616a803bb54f8546af4a067d9fcd44c9e9543b418d1001", + "cairoVersion": "1", + "classHash": "0x06e150953b26271a740bf2b6e9bca17cc52c68d765f761295de51ceb8526ee72", + "id": "0x01060ea20f2778571e616a803bb54f8546af4a067d9fcd44c9e9543b418d1001::sepolia-alpha::local_secret::1", + "index": 1, + "name": "Multisig 2", + "needsDeploy": false, + "networkId": "sepolia-alpha", + "signer": { + "derivationPath": "m/2645'/1195502025'/1148870696'/1'/0'/44", + "type": "ledger" + }, + "type": "multisig" + } + ], + "core:actionQueue:inner": [], + "core:debounce:debounce@20s:ActivityService.updateSelectedAccountActivities": 1725359200756, + "core:debounce:debounce@20s:MultisigWorker.updateAll": 1725359200838, + "core:debounce:debounce@20s:TransactionTrackerWorker.trackTransactionsUpdates": 1725359200756, + "core:debounce:debounce@300s:KnownDappsWorker.update": 1725358914006, + "core:debounce:debounce@60s:DiscoverService.updateDiscover": 1725359151598, + "core:debounce:debounce@60s:NetworkWorker.updateNetworkStatuses": 1725359151598, + "core:debounce:debounce@60s:NftsWorker.updateNfts": 1725359151598, + "core:debounce:debounce@60s:TokenWorker.fetchAndUpdateTokenPricesFromBackend": 1725359151598, + "core:debounce:debounce@60s:TokenWorker.onOpenAndUnlocked": 1725359151598, + "core:debounce:debounce@60s:TokenWorker.updateCustomNetworksTokenBalances": 1725359151598, + "core:debounce:debounce@60s:TransactionReviewWorker.runUpdates": 1725359151597, + "core:multisig:pendingTransactions:inner": [ + { + "account": { + "address": "0x051cda0876cb58b05d9a6d9da52b00b8d04733028def56668867e3bffd9fac47", + "id": "0x051cda0876cb58b05d9a6d9da52b00b8d04733028def56668867e3bffd9fac47::sepolia-alpha::ledger::43", + "networkId": "sepolia-alpha" + }, + "approvedSigners": [ + "0x040b7b6e0ca831417a7f6f2938850e8d91d95373eaafac012c95614d68c98e84" + ], + "creator": "0x040b7b6e0ca831417a7f6f2938850e8d91d95373eaafac012c95614d68c98e84", + "id": "ad9d6060-ca7c-4e13-b906-7a773c07a493", + "meta": { + "icon": "SendIcon", + "subtitle": "To: 0x0229...1BC0", + "title": "Send", + "transactionReview": { + "transactions": [ + { + "reviewOfTransaction": { + "assessment": "warn", + "reviews": [ + { + "action": { + "defaultProperties": [ + { + "label": "default_contract", + "token": { + "address": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + "decimals": 18, + "iconUrl": "https://dv3jj1unlp2jl.cloudfront.net/128/color/eth.png", + "name": "Ether", + "symbol": "ETH", + "type": "ERC20", + "unknown": false + }, + "type": "token_address" + }, + { + "calldata": [ + "977312601197437022110323227435224271452751151768572207166894820017262369728", + "30000000000000", + "0" + ], + "entrypoint": "transfer", + "label": "default_call", + "type": "calldata" + } + ], + "name": "ERC20_transfer", + "properties": [ + { + "amount": "30000000000000", + "editable": false, + "label": "ERC20_transfer_amount", + "token": { + "address": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + "decimals": 18, + "iconUrl": "https://dv3jj1unlp2jl.cloudfront.net/128/color/eth.png", + "name": "Ether", + "symbol": "ETH", + "type": "ERC20", + "unknown": false + }, + "type": "amount", + "usd": "0.08" + }, + { + "address": "0x022923ab1d747a5f534a27c9ff7fd9ba3d7cd2d7142c46f7821dd07b9d0d1bc0", + "label": "ERC20_transfer_recipient", + "type": "address", + "verified": false + } + ] + }, + "assessment": "warn", + "warnings": [ + { + "details": {}, + "reason": "undeployed_account", + "severity": "caution" + } + ] + } + ], + "warnings": [ + { + "details": {}, + "reason": "undeployed_account", + "severity": "caution" + } + ] + }, + "simulation": { + "approvals": [], + "calculatedNonce": "0x1", + "feeEstimation": { + "gasPrice": 55007630780, + "gasUsage": 2773, + "maxFee": 457608456839219, + "overallFee": 152536160152940, + "unit": "WEI" + }, + "summary": [ + { + "label": "simulation_summary_send", + "sent": true, + "token": { + "address": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + "decimals": 18, + "iconUrl": "https://dv3jj1unlp2jl.cloudfront.net/128/color/eth.png", + "name": "Ether", + "symbol": "ETH", + "type": "ERC20", + "unknown": false + }, + "type": "transfer", + "usdValue": "0.08", + "value": "30000000000000" + } + ], + "transfers": [ + { + "details": { + "address": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + "decimals": 18, + "iconUrl": "https://dv3jj1unlp2jl.cloudfront.net/128/color/eth.png", + "name": "Ether", + "symbol": "ETH", + "type": "ERC20", + "unknown": false + }, + "from": "0x051cda0876cb58b05d9a6d9da52b00b8d04733028def56668867e3bffd9fac47", + "to": "0x022923ab1d747a5f534a27c9ff7fd9ba3d7cd2d7142c46f7821dd07b9d0d1bc0", + "tokenAddress": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + "value": "30000000000000" + } + ] + } + } + ] + } + }, + "multisigAddress": "0x051cda0876cb58b05d9a6d9da52b00b8d04733028def56668867e3bffd9fac47", + "nonApprovedSigners": [ + "0x01fbb2e03e41d28b0d572d48c2ef4bb1d2686f37f702e26f179c5101e0587048", + "0x00826a255157914857abb3619d8930a8f43c88923dface0583c81c7289f52b31" + ], + "nonce": 4, + "notify": false, + "requestId": "ad9d6060-ca7c-4e13-b906-7a773c07a493", + "state": "AWAITING_SIGNATURES", + "timestamp": 1725357493163, + "transaction": { + "calls": [ + { + "calldata": [ + "0x022923ab1d747a5f534a27c9ff7fd9ba3d7cd2d7142c46f7821dd07b9d0d1bc0", + "0x00000000000000000000000000000000000000000000000000001b48eb57e000", + "0x0000000000000000000000000000000000000000000000000000000000000000" + ], + "contractAddress": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + "entrypoint": "transfer" + } + ], + "maxFee": "0x0000000000000000000000000000000000000000000000000001a03145842033", + "nonce": "0x0000000000000000000000000000000000000000000000000000000000000004", + "version": "0x0000000000000000000000000000000000000000000000000000000000000001" + }, + "transactionHash": "0x0459a8de687af7f8bfbf860dbf238ac3201edc62bb4e71506fa4a37dc4a5c631", + "type": "INVOKE" + }, + { + "account": { + "address": "0x01060ea20f2778571e616a803bb54f8546af4a067d9fcd44c9e9543b418d1001", + "id": "0x01060ea20f2778571e616a803bb54f8546af4a067d9fcd44c9e9543b418d1001::sepolia-alpha::local_secret::1", + "networkId": "sepolia-alpha" + }, + "approvedSigners": [ + "0x04f7808289a0cec299263ffa85d2c929b823d8849db6994173865f7208a907cd", + "0x02e3bbad3710ff3dc235c0d9b165346b863e58a4459a81ee2bfdc2299e3ea2e3" + ], + "creator": "0x02e3bbad3710ff3dc235c0d9b165346b863e58a4459a81ee2bfdc2299e3ea2e3", + "id": "7cb88aa3-68c8-4ab0-8064-793eb59314ef", + "meta": { + "icon": "SendIcon", + "subtitle": "To: 0x0229...1BC0", + "title": "Send", + "transactionReview": { + "transactions": [ + { + "reviewOfTransaction": { + "assessment": "warn", + "reviews": [ + { + "action": { + "defaultProperties": [ + { + "label": "default_contract", + "token": { + "address": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + "decimals": 18, + "iconUrl": "https://dv3jj1unlp2jl.cloudfront.net/128/color/eth.png", + "name": "Ether", + "symbol": "ETH", + "type": "ERC20", + "unknown": false + }, + "type": "token_address" + }, + { + "calldata": [ + "977312601197437022110323227435224271452751151768572207166894820017262369728", + "20000000000000", + "0" + ], + "entrypoint": "transfer", + "label": "default_call", + "type": "calldata" + } + ], + "name": "ERC20_transfer", + "properties": [ + { + "amount": "20000000000000", + "editable": false, + "label": "ERC20_transfer_amount", + "token": { + "address": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + "decimals": 18, + "iconUrl": "https://dv3jj1unlp2jl.cloudfront.net/128/color/eth.png", + "name": "Ether", + "symbol": "ETH", + "type": "ERC20", + "unknown": false + }, + "type": "amount", + "usd": "0.05" + }, + { + "address": "0x022923ab1d747a5f534a27c9ff7fd9ba3d7cd2d7142c46f7821dd07b9d0d1bc0", + "label": "ERC20_transfer_recipient", + "type": "address", + "verified": false + } + ] + }, + "assessment": "warn", + "warnings": [ + { + "details": {}, + "reason": "undeployed_account", + "severity": "caution" + } + ] + } + ], + "warnings": [ + { + "details": {}, + "reason": "undeployed_account", + "severity": "caution" + } + ] + }, + "simulation": { + "approvals": [], + "calculatedNonce": "0x1", + "feeEstimation": { + "gasPrice": 48488786143, + "gasUsage": 2773, + "maxFee": 403378203923823, + "overallFee": 134459403974539, + "unit": "WEI" + }, + "summary": [ + { + "label": "simulation_summary_send", + "sent": true, + "token": { + "address": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + "decimals": 18, + "iconUrl": "https://dv3jj1unlp2jl.cloudfront.net/128/color/eth.png", + "name": "Ether", + "symbol": "ETH", + "type": "ERC20", + "unknown": false + }, + "type": "transfer", + "usdValue": "0.05", + "value": "20000000000000" + } + ], + "transfers": [ + { + "details": { + "address": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + "decimals": 18, + "iconUrl": "https://dv3jj1unlp2jl.cloudfront.net/128/color/eth.png", + "name": "Ether", + "symbol": "ETH", + "type": "ERC20", + "unknown": false + }, + "from": "0x01060ea20f2778571e616a803bb54f8546af4a067d9fcd44c9e9543b418d1001", + "to": "0x022923ab1d747a5f534a27c9ff7fd9ba3d7cd2d7142c46f7821dd07b9d0d1bc0", + "tokenAddress": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + "value": "20000000000000" + } + ] + } + } + ] + } + }, + "multisigAddress": "0x01060ea20f2778571e616a803bb54f8546af4a067d9fcd44c9e9543b418d1001", + "nonApprovedSigners": [], + "nonce": 2, + "notify": false, + "requestId": "7cb88aa3-68c8-4ab0-8064-793eb59314ef", + "state": "AWAITING_SIGNATURES", + "timestamp": 1725359023723, + "transaction": { + "calls": [ + { + "calldata": [ + "0x022923ab1d747a5f534a27c9ff7fd9ba3d7cd2d7142c46f7821dd07b9d0d1bc0", + "0x000000000000000000000000000000000000000000000000000012309ce54000", + "0x0000000000000000000000000000000000000000000000000000000000000000" + ], + "contractAddress": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + "entrypoint": "transfer" + } + ], + "maxFee": "0x00000000000000000000000000000000000000000000000000016edece8e156f", + "nonce": "0x0000000000000000000000000000000000000000000000000000000000000002", + "version": "0x0000000000000000000000000000000000000000000000000000000000000001" + }, + "transactionHash": "0x04aecd6afbc6c8676f2232486d63c74d63bb92210f9b804650fbdcc54bcacfde", + "type": "INVOKE" + }, + { + "account": { + "address": "0x01060ea20f2778571e616a803bb54f8546af4a067d9fcd44c9e9543b418d1001", + "id": "0x01060ea20f2778571e616a803bb54f8546af4a067d9fcd44c9e9543b418d1001::sepolia-alpha::local_secret::1", + "networkId": "sepolia-alpha" + }, + "approvedSigners": [ + "0x02e3bbad3710ff3dc235c0d9b165346b863e58a4459a81ee2bfdc2299e3ea2e3" + ], + "creator": "0x02e3bbad3710ff3dc235c0d9b165346b863e58a4459a81ee2bfdc2299e3ea2e3", + "id": "9a93267c-f0f9-4e9f-ac2a-75ea01d62d23", + "meta": { + "icon": "SendIcon", + "subtitle": "To: 0x0229...1BC0", + "title": "Send", + "transactionReview": { + "transactions": [ + { + "reviewOfTransaction": { + "assessment": "warn", + "reviews": [ + { + "action": { + "defaultProperties": [ + { + "label": "default_contract", + "token": { + "address": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + "decimals": 18, + "iconUrl": "https://dv3jj1unlp2jl.cloudfront.net/128/color/eth.png", + "name": "Ether", + "symbol": "ETH", + "type": "ERC20", + "unknown": false + }, + "type": "token_address" + }, + { + "calldata": [ + "977312601197437022110323227435224271452751151768572207166894820017262369728", + "300000000000000", + "0" + ], + "entrypoint": "transfer", + "label": "default_call", + "type": "calldata" + } + ], + "name": "ERC20_transfer", + "properties": [ + { + "amount": "300000000000000", + "editable": false, + "label": "ERC20_transfer_amount", + "token": { + "address": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + "decimals": 18, + "iconUrl": "https://dv3jj1unlp2jl.cloudfront.net/128/color/eth.png", + "name": "Ether", + "symbol": "ETH", + "type": "ERC20", + "unknown": false + }, + "type": "amount", + "usd": "0.75" + }, + { + "address": "0x022923ab1d747a5f534a27c9ff7fd9ba3d7cd2d7142c46f7821dd07b9d0d1bc0", + "label": "ERC20_transfer_recipient", + "type": "address", + "verified": false + } + ] + }, + "assessment": "warn", + "warnings": [ + { + "details": {}, + "reason": "undeployed_account", + "severity": "caution" + } + ] + } + ], + "warnings": [ + { + "details": {}, + "reason": "undeployed_account", + "severity": "caution" + } + ] + }, + "simulation": { + "approvals": [], + "calculatedNonce": "0x1", + "feeEstimation": { + "gasPrice": 47977936419, + "gasUsage": 2773, + "maxFee": 399128427185761, + "overallFee": 133042817689887, + "unit": "WEI" + }, + "summary": [ + { + "label": "simulation_summary_send", + "sent": true, + "token": { + "address": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + "decimals": 18, + "iconUrl": "https://dv3jj1unlp2jl.cloudfront.net/128/color/eth.png", + "name": "Ether", + "symbol": "ETH", + "type": "ERC20", + "unknown": false + }, + "type": "transfer", + "usdValue": "0.75", + "value": "300000000000000" + } + ], + "transfers": [ + { + "details": { + "address": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + "decimals": 18, + "iconUrl": "https://dv3jj1unlp2jl.cloudfront.net/128/color/eth.png", + "name": "Ether", + "symbol": "ETH", + "type": "ERC20", + "unknown": false + }, + "from": "0x01060ea20f2778571e616a803bb54f8546af4a067d9fcd44c9e9543b418d1001", + "to": "0x022923ab1d747a5f534a27c9ff7fd9ba3d7cd2d7142c46f7821dd07b9d0d1bc0", + "tokenAddress": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + "value": "300000000000000" + } + ] + } + } + ] + } + }, + "multisigAddress": "0x01060ea20f2778571e616a803bb54f8546af4a067d9fcd44c9e9543b418d1001", + "nonApprovedSigners": [ + "0x04f7808289a0cec299263ffa85d2c929b823d8849db6994173865f7208a907cd", + "0x0674870dedc85ff355200bab2d00c145f42d7d67d790b0f479dbdd2a42d55fbf" + ], + "nonce": 4, + "notify": false, + "requestId": "9a93267c-f0f9-4e9f-ac2a-75ea01d62d23", + "state": "AWAITING_SIGNATURES", + "timestamp": 1725359080510, + "transaction": { + "calls": [ + { + "calldata": [ + "0x022923ab1d747a5f534a27c9ff7fd9ba3d7cd2d7142c46f7821dd07b9d0d1bc0", + "0x000000000000000000000000000000000000000000000000000110d9316ec000", + "0x0000000000000000000000000000000000000000000000000000000000000000" + ], + "contractAddress": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + "entrypoint": "transfer" + } + ], + "maxFee": "0x00000000000000000000000000000000000000000000000000016b01541f4661", + "nonce": "0x0000000000000000000000000000000000000000000000000000000000000004", + "version": "0x0000000000000000000000000000000000000000000000000000000000000001" + }, + "transactionHash": "0x059357ccffcf3860b5d55465e8241c0b7e82647f72d4f4382b7da6131b219a38", + "type": "INVOKE" + } + ], + "core:transactionReview:labels:labels": [ + { + "key": "default_contract", + "value": "Contract" + }, + { + "key": "default_call", + "value": "Call" + }, + { + "key": "account_upgrade", + "value": "Upgrade account" + }, + { + "key": "ERC20_approve", + "value": "Approve spending limit" + }, + { + "key": "ERC20_approve_amount", + "value": "Amount" + }, + { + "key": "ERC20_approve_to", + "value": "Approve to" + }, + { + "key": "ERC20_transfer", + "value": "Send" + }, + { + "key": "ERC20_transfer_amount", + "value": "Amount" + }, + { + "key": "ERC20_transfer_recipient", + "value": "Recipient" + }, + { + "key": "Jediswap_swap", + "value": "Swap tokens" + }, + { + "key": "Jediswap_swap_pay_token", + "value": "Pay token" + }, + { + "key": "Jediswap_swap_receive_token", + "value": "Receive token" + }, + { + "key": "Jediswap_swap_deadline", + "value": "Deadline" + }, + { + "key": "Jediswap_swap_recipient", + "value": "Recipient" + }, + { + "key": "Jediswap_add_liquidity", + "value": "Add liquidity to pool" + }, + { + "key": "Jediswap_add_liquidity_token_a", + "value": "Token 1" + }, + { + "key": "Jediswap_add_liquidity_token_b", + "value": "Token 2" + }, + { + "key": "Jediswap_add_liquidity_deadline", + "value": "Deadline" + }, + { + "key": "Jediswap_add_liquidity_recipient", + "value": "Recipient" + }, + { + "key": "Jediswap_remove_liquidity", + "value": "Remove liquidity of pool" + }, + { + "key": "Jediswap_remove_liquidity_token_a", + "value": "Token 1" + }, + { + "key": "Jediswap_remove_liquidity_token_b", + "value": "Token 2" + }, + { + "key": "Jediswap_remove_liquidity_deadline", + "value": "Deadline" + }, + { + "key": "Jediswap_remove_liquidity_recipient", + "value": "Recipient" + }, + { + "key": "ERC721_transferFrom", + "value": "Send" + }, + { + "key": "ERC721_transferFrom_recipient", + "value": "Recipient" + }, + { + "key": "ERC721_transferFrom_token_id", + "value": "Token Id" + }, + { + "key": "account_change_guardian_address", + "value": "New Guardian" + }, + { + "key": "account_multisig_change_threshold", + "value": "Set confirmations" + }, + { + "key": "account_multisig_set_threshold", + "value": "Set confirmations" + }, + { + "key": "account_multisig_add_signers", + "value": "Add owners and set confirmations" + }, + { + "key": "account_multisig_signer", + "value": "Owner" + }, + { + "key": "account_multisig_number_of_signers", + "value": "Number of owners" + }, + { + "key": "account_multisig_remove_signers", + "value": "Remove owners and set confirmations" + }, + { + "key": "simulation_summary_send", + "value": "Send" + }, + { + "key": "simulation_summary_receive", + "value": "Receive" + }, + { + "key": "Avnu_swap", + "value": "Swap tokens" + }, + { + "key": "Avnu_swap_pay_token", + "value": "Pay token" + }, + { + "key": "Avnu_swap_receive_token", + "value": "Receive token" + }, + { + "key": "Avnu_swap_receive_min_token", + "value": "Receive token (at least)" + }, + { + "key": "Avnu_swap_recipient", + "value": "Recipient" + }, + { + "key": "MySwap_swap", + "value": "Swap tokens" + }, + { + "key": "MySwap_swap_pay_token", + "value": "Pay token" + }, + { + "key": "MySwap_swap_receive_min_token", + "value": "Receive token (at least)" + }, + { + "key": "MySwap_swap_pool_id", + "value": "Pool ID" + }, + { + "key": "TenKSwap_add_liquidity", + "value": "Add liquidity to pool" + }, + { + "key": "TenKSwap_add_liquidity_token_a", + "value": "Token 1" + }, + { + "key": "TenKSwap_add_liquidity_token_b", + "value": "Token 2" + }, + { + "key": "TenKSwap_add_liquidity_deadline", + "value": "Deadline" + }, + { + "key": "TenKSwap_add_liquidity_recipient", + "value": "Recipient" + }, + { + "key": "TenKSwap_remove_liquidity", + "value": "Remove liquidity of pool" + }, + { + "key": "TenKSwap_remove_liquidity_token_a", + "value": "Token 1" + }, + { + "key": "TenKSwap_remove_liquidity_token_b", + "value": "Token 2" + }, + { + "key": "TenKSwap_remove_liquidity_deadline", + "value": "Deadline" + }, + { + "key": "TenKSwap_remove_liquidity_recipient", + "value": "Recipient" + }, + { + "key": "TenKSwap_swap", + "value": "Swap tokens" + }, + { + "key": "TenKSwap_swap_pay_token", + "value": "Pay token" + }, + { + "key": "TenKSwap_swap_receive_token", + "value": "Receive token" + }, + { + "key": "TenKSwap_swap_deadline", + "value": "Deadline" + }, + { + "key": "TenKSwap_swap_recipient", + "value": "Recipient" + }, + { + "key": "MySwap_swap_receive_token", + "value": "Receive token" + }, + { + "key": "MySwap_swap_pool_key", + "value": "Pool KEY" + }, + { + "key": "transaction_failure_predicted", + "value": "Transaction failure predicted" + }, + { + "key": "invalid_transaction_nonce", + "value": "Invalid transaction nonce" + }, + { + "key": "transaction_unknown_error", + "value": "Transaction error" + }, + { + "key": "ERC721_setApprovalForAll", + "value": "Approve NFT collection" + }, + { + "key": "ERC721_setApprovalForAll_collection", + "value": "Approve collection" + }, + { + "key": "ERC721_setApprovalForAll_to", + "value": "Approve to" + }, + { + "key": "ERC1155_setApprovalForAll", + "value": "Approve NFT collection" + }, + { + "key": "ERC1155_setApprovalForAll_collection", + "value": "Approve collection" + }, + { + "key": "ERC1155_setApprovalForAll_to", + "value": "Approve to" + }, + { + "key": "ERC20_increaseAllowance", + "value": "Increase spending limit" + }, + { + "key": "ERC20_increaseAllowance_amount", + "value": "Amount" + }, + { + "key": "ERC20_increaseAllowance_to", + "value": "Increase to" + }, + { + "key": "ERC20_decreaseAllowance", + "value": "Decrease spending limit" + }, + { + "key": "ERC20_decreaseAllowance_amount", + "value": "Amount" + }, + { + "key": "ERC20_decreaseAllowance_to", + "value": "Decrease to" + }, + { + "key": "account_upgrade_to_unknown_implementation_title", + "value": "Loss of funds due to invalid update" + }, + { + "key": "account_upgrade_to_unknown_implementation_description", + "value": "You're about to execute an update of your Argent Account which is not verified by us. This is a dangerous operation and might lead to loss of your funds. We strongly advise you to not move forward." + }, + { + "key": "approval_too_high_title", + "value": "Approval of spending limit is too high" + }, + { + "key": "approval_too_high_description", + "value": "You're approving one or more addresses to spend more tokens than you're using in this transaction. These funds will not be spent but you should not proceed if you don’t trust this app." + }, + { + "key": "account_state_change_title", + "value": "Loss of funds due to ownership change" + }, + { + "key": "account_change_guardian_add", + "value": "Add Guardian" + }, + { + "key": "account_change_guardian_remove", + "value": "Remove Guardian" + }, + { + "key": "account_cancel_escape", + "value": "Keep Guardian" + }, + { + "key": "account_state_change_description", + "value": "You're about to change the owner of your account. If you proceed with the transaction, you lose access to your funds. We strongly recommend that you reject the transaction." + }, + { + "key": "contract_is_black_listed_title", + "value": "Smart contract on unsafe list" + }, + { + "key": "contract_is_black_listed_description", + "value": "You are using a smart contract that is on our unsafe list for the following reason: ${reason}. We recommend that you reject the transaction." + }, + { + "key": "similar_to_existing_dapp_url_title", + "value": "Similar to an existing dapp" + }, + { + "key": "similar_to_existing_dapp_url_description", + "value": "You are currently on an unsafe domain. Be aware of the risks." + }, + { + "key": "domain_is_black_listed_title", + "value": "Use of a blacklisted domain" + }, + { + "key": "domain_is_black_listed_description", + "value": "You are currently on an unsafe domain. Be aware of the risks." + }, + { + "key": "amount_mismatch_too_low_title", + "value": "Poor trade/swap of tokens" + }, + { + "key": "amount_mismatch_too_low_description", + "value": "You are swapping two tokens at a poor exchange rate (more than 5%). Make sure that there aren't other options with better rates." + }, + { + "key": "amount_mismatch_too_high_title", + "value": "Uncommon trade/swap of tokens" + }, + { + "key": "amount_mismatch_too_high_description", + "value": "You are swapping two tokens at a rate that is much better than current market rates. You receive more than you invest. Double check if everything is correct." + }, + { + "key": "dst_token_black_listed_title", + "value": "You are buying an unsafe token." + }, + { + "key": "dst_token_black_listed_description", + "value": "You are buying an unsafe token. Be aware of the risks." + }, + { + "key": "internal_service_issue_title", + "value": "Internal issue" + }, + { + "key": "internal_service_issue_description", + "value": "An internal issue occurred. Please try again later. If the issue persists, please contact support." + }, + { + "key": "multi_calls_on_account_title", + "value": "TBD" + }, + { + "key": "multi_calls_on_account_description", + "value": "TBD" + }, + { + "key": "recipient_is_not_current_account_title", + "value": "Sender address of token swap is different to receiver address" + }, + { + "key": "recipient_is_not_current_account_description", + "value": "You are sending tokens for swap, but you won't receive them. If this is not your intention, we strongly recommend to reject the transaction." + }, + { + "key": "recipient_is_token_address_title", + "value": "Unintentional burn of assets" + }, + { + "key": "recipient_is_token_address_description", + "value": "You're sending assets to a smart contract that defines a token. This will likely burn your assets (forever). Please double check if this is really your intent." + }, + { + "key": "recipient_is_black_listed_title", + "value": "Recipient on unsafe list" + }, + { + "key": "recipient_is_black_listed_description", + "value": "You are sending to an unsafe contract that is blacklisted for the the following reason: ${reason}." + }, + { + "key": "spender_is_black_listed_title", + "value": "Spender on unsafe list" + }, + { + "key": "spender_is_black_listed_description", + "value": "You are allowing an unsafe contract to access your funds. We deem this contract unsafe for the following reason: ${reason}." + }, + { + "key": "operator_is_black_listed_title", + "value": "Spender on unsafe list" + }, + { + "key": "operator_is_black_listed_description", + "value": "You are allowing an unsafe contract to access your funds. We deem this contract unsafe for the following reason: ${reason}." + }, + { + "key": "src_token_black_listed_title", + "value": "Trade of an unsafe token" + }, + { + "key": "src_token_black_listed_description", + "value": "You are selling an unsafe token. Be aware of the risks." + }, + { + "key": "token_a_black_listed_title", + "value": "Use of an unsafe token" + }, + { + "key": "token_a_black_listed_description", + "value": "You are using an unsafe token. Be aware of the risks." + }, + { + "key": "token_b_black_listed_title", + "value": "Use of an unsafe token" + }, + { + "key": "token_b_black_listed_description", + "value": "You are using an unsafe token. Be aware of the risks." + }, + { + "key": "unknown_token_title", + "value": "Unknown token" + }, + { + "key": "unknown_token_description", + "value": "You're interacting with a token smart contract that is not known to our registries. Make sure that you trust the application and it is the correct token." + }, + { + "key": "contract_is_not_verified_title", + "value": "Unverified smart contracts" + }, + { + "key": "contract_is_not_verified_description", + "value": "The dapp you're using has not opened its source code on the block explorers Starkscan or Voyager. This means that no one can check what the smart contract actually does. Make sure you trust the app before you proceed." + }, + { + "key": "undeployed_account_title", + "value": "Sending to the correct account?" + }, + { + "key": "undeployed_account_description", + "value": "The account you are sending to hasn't done any transactions, please double check the address" + }, + { + "key": "activity_title_trade", + "value": "Swap" + }, + { + "key": "activity_title_deploy", + "value": "Account activation" + }, + { + "key": "activity_title_approval_revoke", + "value": "Revoke" + }, + { + "key": "activity_title_approval_approve", + "value": "Approve" + }, + { + "key": "activity_title_security_change_guardian", + "value": "Change guardian" + }, + { + "key": "activity_title_security_account_upgrade", + "value": "Account upgrade" + }, + { + "key": "activity_title_multisend", + "value": "Send to many" + }, + { + "key": "activity_title_payment_outbound", + "value": "Send" + }, + { + "key": "activity_title_payment_inbound_provision_airdrop", + "value": "Receive airdrop" + }, + { + "key": "activity_title_payment_inbound_onramp", + "value": "Buy %s" + }, + { + "key": "activity_title_payment_inbound_mint_spok", + "value": "Minted SPOK" + }, + { + "key": "activity_title_payment_inbound_mint_other", + "value": "Minted collectible" + }, + { + "key": "activity_title_payment_inbound", + "value": "Receive" + }, + { + "key": "Insufficient tokens received", + "value": "Insufficient tokens received. Adjust slippage." + }, + { + "key": "approval_too_high_soft_title", + "value": "Approval of spending limit is too high" + }, + { + "key": "approval_too_high_soft_description", + "value": "You're approving one or more addresses to spend more tokens than you're using in this transaction. These funds will not be spent but you should not proceed if you don’t trust this app." + }, + { + "key": "account_multisig_replace_signer", + "value": "Replace owner" + }, + { + "key": "account_multisig_replace_signer_removed_signer", + "value": "Owner to be removed" + }, + { + "key": "account_multisig_replace_signer_added_signer", + "value": "Owner to be added" + }, + { + "key": "StarknetBridge_withdraw", + "value": "Withdraw" + }, + { + "key": "StarknetBridge_withdraw_amount", + "value": "Amount" + }, + { + "key": "StarknetBridge_withdraw_l1_recipient", + "value": "L1 Recipient" + } + ], + "core:transactionReview:labels:updatedAt": 1725357313556, + "core:transactionReview:warnings:updatedAt": 1725357313741, + "core:transactionReview:warnings:warnings": [ + { + "description": "You're about to execute an update of your Argent Account which is not verified by us. This is a dangerous operation and might lead to loss of your funds. We strongly advise you to not move forward.", + "reason": "account_upgrade_to_unknown_implementation", + "severity": "caution", + "title": "Loss of funds due to invalid update" + }, + { + "description": "You're approving one or more addresses to spend more tokens than you're using in this transaction. These funds will not be spent but you should not proceed if you don’t trust this app.", + "reason": "approval_too_high", + "severity": "caution", + "title": "Approval of spending limit is too high" + }, + { + "description": "You're approving one or more addresses to spend more tokens than you're using in this transaction. These funds will not be spent but you should not proceed if you don’t trust this app.", + "reason": "approval_too_high_soft", + "severity": "caution", + "title": "Approval of spending limit is too high" + }, + { + "description": "You're about to change the owner of your account. If you proceed with the transaction, you lose access to your funds. We strongly recommend that you reject the transaction.", + "reason": "account_state_change", + "severity": "caution", + "title": "Loss of funds due to ownership change" + }, + { + "description": "You are using a smart contract that is on our unsafe list for the following reason: ${reason}. We recommend that you reject the transaction.", + "reason": "contract_is_black_listed", + "severity": "caution", + "title": "Smart contract on unsafe list" + }, + { + "description": "You are currently on an unsafe domain. Be aware of the risks.", + "reason": "similar_to_existing_dapp_url", + "severity": "critical", + "title": "Similar to an existing dapp" + }, + { + "description": "You are currently on an unsafe domain. Be aware of the risks.", + "reason": "domain_is_black_listed", + "severity": "critical", + "title": "Use of a blacklisted domain" + }, + { + "description": "You are swapping two tokens at a poor exchange rate (more than 5%). Make sure that there aren't other options with better rates.", + "reason": "amount_mismatch_too_low", + "severity": "caution", + "title": "Poor trade/swap of tokens" + }, + { + "description": "You are swapping two tokens at a rate that is much better than current market rates. You receive more than you invest. Double check if everything is correct.", + "reason": "amount_mismatch_too_high", + "severity": "caution", + "title": "Uncommon trade/swap of tokens" + }, + { + "description": "You are buying an unsafe token. Be aware of the risks.", + "reason": "dst_token_black_listed", + "severity": "caution", + "title": "You are buying an unsafe token." + }, + { + "description": "An internal issue occurred. Please try again later. If the issue persists, please contact support.", + "reason": "internal_service_issue", + "severity": "caution", + "title": "Internal issue" + }, + { + "description": "TBD", + "reason": "multi_calls_on_account", + "severity": "caution", + "title": "TBD" + }, + { + "description": "You are sending tokens for swap, but you won't receive them. If this is not your intention, we strongly recommend to reject the transaction.", + "reason": "recipient_is_not_current_account", + "severity": "caution", + "title": "Sender address of token swap is different to receiver address" + }, + { + "description": "You're sending assets to a smart contract that defines a token. This will likely burn your assets (forever). Please double check if this is really your intent.", + "reason": "recipient_is_token_address", + "severity": "caution", + "title": "Unintentional burn of assets" + }, + { + "description": "You are sending to an unsafe contract that is blacklisted for the the following reason: ${reason}.", + "reason": "recipient_is_black_listed", + "severity": "caution", + "title": "Recipient on unsafe list" + }, + { + "description": "You are allowing an unsafe contract to access your funds. We deem this contract unsafe for the following reason: ${reason}.", + "reason": "spender_is_black_listed", + "severity": "caution", + "title": "Spender on unsafe list" + }, + { + "description": "You are allowing an unsafe contract to access your funds. We deem this contract unsafe for the following reason: ${reason}.", + "reason": "operator_is_black_listed", + "severity": "caution", + "title": "Spender on unsafe list" + }, + { + "description": "You are selling an unsafe token. Be aware of the risks.", + "reason": "src_token_black_listed", + "severity": "caution", + "title": "Trade of an unsafe token" + }, + { + "description": "You are using an unsafe token. Be aware of the risks.", + "reason": "token_a_black_listed", + "severity": "caution", + "title": "Use of an unsafe token" + }, + { + "description": "You are using an unsafe token. Be aware of the risks.", + "reason": "token_b_black_listed", + "severity": "caution", + "title": "Use of an unsafe token" + }, + { + "description": "You're interacting with a token smart contract that is not known to our registries. Make sure that you trust the application and it is the correct token.", + "reason": "unknown_token", + "severity": "caution", + "title": "Unknown token" + }, + { + "description": "The dapp you're using has not opened its source code on the block explorers Starkscan or Voyager. This means that no one can check what the smart contract actually does. Make sure you trust the app before you proceed.", + "reason": "contract_is_not_verified", + "severity": "caution", + "title": "Unverified smart contracts" + }, + { + "description": "The account you are sending to hasn't done any transactions, please double check the address", + "reason": "undeployed_account", + "severity": "caution", + "title": "Sending to the correct account?" + } + ], + "core:transactions:inner": [ + { + "account": { + "address": "0x51cda0876cb58b05d9a6d9da52b00b8d04733028def56668867e3bffd9fac47", + "cairoVersion": "1", + "classHash": "0x06e150953b26271a740bf2b6e9bca17cc52c68d765f761295de51ceb8526ee72", + "id": "0x051cda0876cb58b05d9a6d9da52b00b8d04733028def56668867e3bffd9fac47::sepolia-alpha::ledger::43", + "index": 0, + "name": "Multisig 1", + "needsDeploy": false, + "network": { + "accountClassHash": { + "multisig": "0x6e150953b26271a740bf2b6e9bca17cc52c68d765f761295de51ceb8526ee72", + "smart": "0x036078334509b514626504edc9fb252328d1a240e4e948bef8d0c08dff45927f", + "standard": "0x036078334509b514626504edc9fb252328d1a240e4e948bef8d0c08dff45927f", + "standardCairo0": "0x033434ad846cdd5f23eb73ff09fe6fddd568284a0fb7d1be20ee482f044dabe2" + }, + "chainId": "SN_SEPOLIA", + "explorerUrl": "https://sepolia.voyager.online", + "id": "sepolia-alpha", + "l1ExplorerUrl": "https://sepolia.etherscan.io", + "multicallAddress": "0x05754af3760f3356da99aea5c3ec39ccac7783d925a19666ebbeca58ff0087f4", + "name": "Sepolia", + "possibleFeeTokenAddresses": [ + "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + "0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d" + ], + "readonly": true, + "rpcUrl": "https://api.hydrogen.argent47.net/v1/starknet/sepolia/rpc/v0.7" + }, + "networkId": "sepolia-alpha", + "signer": { + "derivationPath": "m/44'/9004'/1'/0/0", + "type": "local_secret" + }, + "type": "multisig" + }, + "hash": "0x0459a8de687af7f8bfbf860dbf238ac3201edc62bb4e71506fa4a37dc4a5c631", + "meta": { + "ampliProperties": { + "account index": 0, + "account type": "multisig", + "is deployment": false, + "token addresses": [ + "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7" + ], + "transaction type": "inapp send", + "wallet platform": "browser extension" + }, + "isMaxSend": false, + "title": "Transfer", + "transactions": { + "calldata": [ + "977312601197437022110323227435224271452751151768572207166894820017262369728", + "30000000000000", + "0" + ], + "contractAddress": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + "entrypoint": "transfer" + }, + "type": "INVOKE" + }, + "status": { + "finality_status": "NOT_RECEIVED" + }, + "timestamp": 1725357493 + }, + { + "account": { + "address": "0x51cda0876cb58b05d9a6d9da52b00b8d04733028def56668867e3bffd9fac47", + "cairoVersion": "1", + "classHash": "0x06e150953b26271a740bf2b6e9bca17cc52c68d765f761295de51ceb8526ee72", + "id": "0x051cda0876cb58b05d9a6d9da52b00b8d04733028def56668867e3bffd9fac47::sepolia-alpha::ledger::43", + "index": 0, + "name": "Multisig 1", + "needsDeploy": false, + "network": { + "accountClassHash": { + "multisig": "0x6e150953b26271a740bf2b6e9bca17cc52c68d765f761295de51ceb8526ee72", + "smart": "0x036078334509b514626504edc9fb252328d1a240e4e948bef8d0c08dff45927f", + "standard": "0x036078334509b514626504edc9fb252328d1a240e4e948bef8d0c08dff45927f", + "standardCairo0": "0x033434ad846cdd5f23eb73ff09fe6fddd568284a0fb7d1be20ee482f044dabe2" + }, + "chainId": "SN_SEPOLIA", + "explorerUrl": "https://sepolia.voyager.online", + "id": "sepolia-alpha", + "l1ExplorerUrl": "https://sepolia.etherscan.io", + "multicallAddress": "0x05754af3760f3356da99aea5c3ec39ccac7783d925a19666ebbeca58ff0087f4", + "name": "Sepolia", + "possibleFeeTokenAddresses": [ + "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + "0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d" + ], + "readonly": true, + "rpcUrl": "https://api.hydrogen.argent47.net/v1/starknet/sepolia/rpc/v0.7" + }, + "networkId": "sepolia-alpha", + "signer": { + "derivationPath": "m/2645'/1195502025'/1148870696'/1'/0'/43", + "type": "ledger" + }, + "type": "multisig" + }, + "hash": "0x5b89ba4120fab620dfbc33cfef3a0f1235d55e8b5afe3cafd8fb28aa1eca8fe", + "status": {}, + "timestamp": 1725357497 + }, + { + "account": { + "address": "0x51cda0876cb58b05d9a6d9da52b00b8d04733028def56668867e3bffd9fac47", + "cairoVersion": "1", + "classHash": "0x06e150953b26271a740bf2b6e9bca17cc52c68d765f761295de51ceb8526ee72", + "id": "0x051cda0876cb58b05d9a6d9da52b00b8d04733028def56668867e3bffd9fac47::sepolia-alpha::ledger::43", + "index": 0, + "name": "Multisig 1", + "needsDeploy": false, + "network": { + "accountClassHash": { + "multisig": "0x6e150953b26271a740bf2b6e9bca17cc52c68d765f761295de51ceb8526ee72", + "smart": "0x036078334509b514626504edc9fb252328d1a240e4e948bef8d0c08dff45927f", + "standard": "0x036078334509b514626504edc9fb252328d1a240e4e948bef8d0c08dff45927f", + "standardCairo0": "0x033434ad846cdd5f23eb73ff09fe6fddd568284a0fb7d1be20ee482f044dabe2" + }, + "chainId": "SN_SEPOLIA", + "explorerUrl": "https://sepolia.voyager.online", + "id": "sepolia-alpha", + "l1ExplorerUrl": "https://sepolia.etherscan.io", + "multicallAddress": "0x05754af3760f3356da99aea5c3ec39ccac7783d925a19666ebbeca58ff0087f4", + "name": "Sepolia", + "possibleFeeTokenAddresses": [ + "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + "0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d" + ], + "readonly": true, + "rpcUrl": "https://api.hydrogen.argent47.net/v1/starknet/sepolia/rpc/v0.7" + }, + "networkId": "sepolia-alpha", + "signer": { + "derivationPath": "m/2645'/1195502025'/1148870696'/1'/0'/43", + "type": "ledger" + }, + "type": "multisig" + }, + "hash": "0x535bbf8e2eef80c0544769250578b2d4ae834ba39945e04bc7928f14080fe24", + "status": {}, + "timestamp": 1725357497 + }, + { + "account": { + "address": "0x51cda0876cb58b05d9a6d9da52b00b8d04733028def56668867e3bffd9fac47", + "cairoVersion": "1", + "classHash": "0x06e150953b26271a740bf2b6e9bca17cc52c68d765f761295de51ceb8526ee72", + "id": "0x051cda0876cb58b05d9a6d9da52b00b8d04733028def56668867e3bffd9fac47::sepolia-alpha::ledger::43", + "index": 0, + "name": "Multisig 1", + "needsDeploy": false, + "network": { + "accountClassHash": { + "multisig": "0x6e150953b26271a740bf2b6e9bca17cc52c68d765f761295de51ceb8526ee72", + "smart": "0x036078334509b514626504edc9fb252328d1a240e4e948bef8d0c08dff45927f", + "standard": "0x036078334509b514626504edc9fb252328d1a240e4e948bef8d0c08dff45927f", + "standardCairo0": "0x033434ad846cdd5f23eb73ff09fe6fddd568284a0fb7d1be20ee482f044dabe2" + }, + "chainId": "SN_SEPOLIA", + "explorerUrl": "https://sepolia.voyager.online", + "id": "sepolia-alpha", + "l1ExplorerUrl": "https://sepolia.etherscan.io", + "multicallAddress": "0x05754af3760f3356da99aea5c3ec39ccac7783d925a19666ebbeca58ff0087f4", + "name": "Sepolia", + "possibleFeeTokenAddresses": [ + "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + "0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d" + ], + "readonly": true, + "rpcUrl": "https://api.hydrogen.argent47.net/v1/starknet/sepolia/rpc/v0.7" + }, + "networkId": "sepolia-alpha", + "signer": { + "derivationPath": "m/2645'/1195502025'/1148870696'/1'/0'/43", + "type": "ledger" + }, + "type": "multisig" + }, + "hash": "0x49924a6583e6524c95e0b38ab3ef3757899b62ef26c8c70dfffb847e2ddaa37", + "status": {}, + "timestamp": 1725357497 + }, + { + "account": { + "address": "0x1060ea20f2778571e616a803bb54f8546af4a067d9fcd44c9e9543b418d1001", + "cairoVersion": "1", + "classHash": "0x06e150953b26271a740bf2b6e9bca17cc52c68d765f761295de51ceb8526ee72", + "creator": "0x01060ea20f2778571e616a803bb54f8546af4a067d9fcd44c9e9543b418d1001", + "id": "0x01060ea20f2778571e616a803bb54f8546af4a067d9fcd44c9e9543b418d1001::sepolia-alpha::local_secret::1", + "index": 1, + "name": "Multisig 2", + "needsDeploy": false, + "network": { + "accountClassHash": { + "multisig": "0x6e150953b26271a740bf2b6e9bca17cc52c68d765f761295de51ceb8526ee72", + "smart": "0x036078334509b514626504edc9fb252328d1a240e4e948bef8d0c08dff45927f", + "standard": "0x036078334509b514626504edc9fb252328d1a240e4e948bef8d0c08dff45927f", + "standardCairo0": "0x033434ad846cdd5f23eb73ff09fe6fddd568284a0fb7d1be20ee482f044dabe2" + }, + "chainId": "SN_SEPOLIA", + "explorerUrl": "https://sepolia.voyager.online", + "id": "sepolia-alpha", + "l1ExplorerUrl": "https://sepolia.etherscan.io", + "multicallAddress": "0x05754af3760f3356da99aea5c3ec39ccac7783d925a19666ebbeca58ff0087f4", + "name": "Sepolia", + "possibleFeeTokenAddresses": [ + "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + "0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d" + ], + "readonly": true, + "rpcUrl": "https://api.hydrogen.argent47.net/v1/starknet/sepolia/rpc/v0.7" + }, + "networkId": "sepolia-alpha", + "pendingSigner": { + "pubKey": "0x0674870dedc85ff355200bab2d00c145f42d7d67d790b0f479dbdd2a42d55fbf", + "signer": { + "derivationPath": "m/2645'/1195502025'/1148870696'/1'/0'/44", + "type": "ledger" + } + }, + "publicKey": "0x02e3bbad3710ff3dc235c0d9b165346b863e58a4459a81ee2bfdc2299e3ea2e3", + "signer": { + "derivationPath": "m/44'/9004'/1'/0/1", + "type": "local_secret" + }, + "signers": [ + "0x02e3bbad3710ff3dc235c0d9b165346b863e58a4459a81ee2bfdc2299e3ea2e3", + "0x04f7808289a0cec299263ffa85d2c929b823d8849db6994173865f7208a907cd" + ], + "threshold": 2, + "type": "multisig", + "updatedAt": 1725359181005 + }, + "hash": "0x0758e1cf3753b1fa5fdaf82c7355c5c651a6153fb131864f7be0d35ee901d1d8", + "meta": { + "transactions": [ + { + "calldata": [ + "0x022923ab1d747a5f534a27c9ff7fd9ba3d7cd2d7142c46f7821dd07b9d0d1bc0", + "0x000000000000000000000000000000000000000000000000000009184e72a000", + "0x0000000000000000000000000000000000000000000000000000000000000000" + ], + "contractAddress": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + "entrypoint": "transfer" + } + ], + "type": "INVOKE" + }, + "status": { + "finality_status": "ACCEPTED_ON_L2" + }, + "timestamp": 1725359181 + }, + { + "account": { + "address": "0x1060ea20f2778571e616a803bb54f8546af4a067d9fcd44c9e9543b418d1001", + "cairoVersion": "1", + "classHash": "0x06e150953b26271a740bf2b6e9bca17cc52c68d765f761295de51ceb8526ee72", + "id": "0x01060ea20f2778571e616a803bb54f8546af4a067d9fcd44c9e9543b418d1001::sepolia-alpha::local_secret::1", + "index": 1, + "name": "Multisig 2", + "needsDeploy": false, + "network": { + "accountClassHash": { + "multisig": "0x6e150953b26271a740bf2b6e9bca17cc52c68d765f761295de51ceb8526ee72", + "smart": "0x036078334509b514626504edc9fb252328d1a240e4e948bef8d0c08dff45927f", + "standard": "0x036078334509b514626504edc9fb252328d1a240e4e948bef8d0c08dff45927f", + "standardCairo0": "0x033434ad846cdd5f23eb73ff09fe6fddd568284a0fb7d1be20ee482f044dabe2" + }, + "chainId": "SN_SEPOLIA", + "explorerUrl": "https://sepolia.voyager.online", + "id": "sepolia-alpha", + "l1ExplorerUrl": "https://sepolia.etherscan.io", + "multicallAddress": "0x05754af3760f3356da99aea5c3ec39ccac7783d925a19666ebbeca58ff0087f4", + "name": "Sepolia", + "possibleFeeTokenAddresses": [ + "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + "0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d" + ], + "readonly": true, + "rpcUrl": "https://api.hydrogen.argent47.net/v1/starknet/sepolia/rpc/v0.7" + }, + "networkId": "sepolia-alpha", + "signer": { + "derivationPath": "m/44'/9004'/1'/0/1", + "type": "local_secret" + }, + "type": "multisig" + }, + "hash": "0x04aecd6afbc6c8676f2232486d63c74d63bb92210f9b804650fbdcc54bcacfde", + "meta": { + "ampliProperties": { + "account index": 1, + "account type": "multisig", + "is deployment": false, + "token addresses": [ + "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7" + ], + "transaction type": "inapp send", + "wallet platform": "browser extension" + }, + "isMaxSend": false, + "title": "Transfer", + "transactions": { + "calldata": [ + "977312601197437022110323227435224271452751151768572207166894820017262369728", + "20000000000000", + "0" + ], + "contractAddress": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + "entrypoint": "transfer" + }, + "type": "INVOKE" + }, + "status": { + "finality_status": "NOT_RECEIVED" + }, + "timestamp": 1725359023 + }, + { + "account": { + "address": "0x1060ea20f2778571e616a803bb54f8546af4a067d9fcd44c9e9543b418d1001", + "cairoVersion": "1", + "classHash": "0x06e150953b26271a740bf2b6e9bca17cc52c68d765f761295de51ceb8526ee72", + "creator": "0x01060ea20f2778571e616a803bb54f8546af4a067d9fcd44c9e9543b418d1001", + "id": "0x01060ea20f2778571e616a803bb54f8546af4a067d9fcd44c9e9543b418d1001::sepolia-alpha::local_secret::1", + "index": 1, + "name": "Multisig 2", + "needsDeploy": false, + "network": { + "accountClassHash": { + "multisig": "0x6e150953b26271a740bf2b6e9bca17cc52c68d765f761295de51ceb8526ee72", + "smart": "0x036078334509b514626504edc9fb252328d1a240e4e948bef8d0c08dff45927f", + "standard": "0x036078334509b514626504edc9fb252328d1a240e4e948bef8d0c08dff45927f", + "standardCairo0": "0x033434ad846cdd5f23eb73ff09fe6fddd568284a0fb7d1be20ee482f044dabe2" + }, + "chainId": "SN_SEPOLIA", + "explorerUrl": "https://sepolia.voyager.online", + "id": "sepolia-alpha", + "l1ExplorerUrl": "https://sepolia.etherscan.io", + "multicallAddress": "0x05754af3760f3356da99aea5c3ec39ccac7783d925a19666ebbeca58ff0087f4", + "name": "Sepolia", + "possibleFeeTokenAddresses": [ + "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + "0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d" + ], + "readonly": true, + "rpcUrl": "https://api.hydrogen.argent47.net/v1/starknet/sepolia/rpc/v0.7" + }, + "networkId": "sepolia-alpha", + "pendingSigner": { + "pubKey": "0x0674870dedc85ff355200bab2d00c145f42d7d67d790b0f479dbdd2a42d55fbf", + "signer": { + "derivationPath": "m/2645'/1195502025'/1148870696'/1'/0'/44", + "type": "ledger" + } + }, + "publicKey": "0x02e3bbad3710ff3dc235c0d9b165346b863e58a4459a81ee2bfdc2299e3ea2e3", + "signer": { + "derivationPath": "m/44'/9004'/1'/0/1", + "type": "local_secret" + }, + "signers": [ + "0x02e3bbad3710ff3dc235c0d9b165346b863e58a4459a81ee2bfdc2299e3ea2e3", + "0x04f7808289a0cec299263ffa85d2c929b823d8849db6994173865f7208a907cd" + ], + "threshold": 2, + "type": "multisig", + "updatedAt": 1725359181005 + }, + "hash": "0x064c06998e11dd59098f580a588f75ec0387ea7e971511f6721cfd46b526b27b", + "meta": { + "transactions": [ + { + "calldata": [ + "0x02e3bbad3710ff3dc235c0d9b165346b863e58a4459a81ee2bfdc2299e3ea2e3", + "0x0674870dedc85ff355200bab2d00c145f42d7d67d790b0f479dbdd2a42d55fbf" + ], + "contractAddress": "0x01060ea20f2778571e616a803bb54f8546af4a067d9fcd44c9e9543b418d1001", + "entrypoint": "replace_signer" + } + ], + "type": "INVOKE" + }, + "status": { + "finality_status": "ACCEPTED_ON_L2" + }, + "timestamp": 1725359200 + }, + { + "account": { + "address": "0x1060ea20f2778571e616a803bb54f8546af4a067d9fcd44c9e9543b418d1001", + "cairoVersion": "1", + "classHash": "0x06e150953b26271a740bf2b6e9bca17cc52c68d765f761295de51ceb8526ee72", + "id": "0x01060ea20f2778571e616a803bb54f8546af4a067d9fcd44c9e9543b418d1001::sepolia-alpha::local_secret::1", + "index": 1, + "name": "Multisig 2", + "needsDeploy": false, + "network": { + "accountClassHash": { + "multisig": "0x6e150953b26271a740bf2b6e9bca17cc52c68d765f761295de51ceb8526ee72", + "smart": "0x036078334509b514626504edc9fb252328d1a240e4e948bef8d0c08dff45927f", + "standard": "0x036078334509b514626504edc9fb252328d1a240e4e948bef8d0c08dff45927f", + "standardCairo0": "0x033434ad846cdd5f23eb73ff09fe6fddd568284a0fb7d1be20ee482f044dabe2" + }, + "chainId": "SN_SEPOLIA", + "explorerUrl": "https://sepolia.voyager.online", + "id": "sepolia-alpha", + "l1ExplorerUrl": "https://sepolia.etherscan.io", + "multicallAddress": "0x05754af3760f3356da99aea5c3ec39ccac7783d925a19666ebbeca58ff0087f4", + "name": "Sepolia", + "possibleFeeTokenAddresses": [ + "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + "0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d" + ], + "readonly": true, + "rpcUrl": "https://api.hydrogen.argent47.net/v1/starknet/sepolia/rpc/v0.7" + }, + "networkId": "sepolia-alpha", + "signer": { + "derivationPath": "m/44'/9004'/1'/0/1", + "type": "local_secret" + }, + "type": "multisig" + }, + "hash": "0x059357ccffcf3860b5d55465e8241c0b7e82647f72d4f4382b7da6131b219a38", + "meta": { + "ampliProperties": { + "account index": 1, + "account type": "multisig", + "is deployment": false, + "token addresses": [ + "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7" + ], + "transaction type": "inapp send", + "wallet platform": "browser extension" + }, + "isMaxSend": false, + "title": "Transfer", + "transactions": { + "calldata": [ + "977312601197437022110323227435224271452751151768572207166894820017262369728", + "300000000000000", + "0" + ], + "contractAddress": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + "entrypoint": "transfer" + }, + "type": "INVOKE" + }, + "status": { + "finality_status": "NOT_RECEIVED" + }, + "timestamp": 1725359080 + } + ], + "core:wallet:backup": "{\"address\":\"472cad6bbd47afd1cdfded81171112a452b4d441\",\"id\":\"260b5db4-5be9-4701-9858-49fdc7d38a8a\",\"version\":3,\"Crypto\":{\"cipher\":\"aes-128-ctr\",\"cipherparams\":{\"iv\":\"0868ff43445abd3a945bb68c6e542f43\"},\"ciphertext\":\"4dde4103451f98d60ec5a02aef004dc2a59a9f4ea8b70e7dbd0945260895ab56\",\"kdf\":\"scrypt\",\"kdfparams\":{\"salt\":\"392f267ee89f6581536a60823ff9c0c98721dbf8919a4682b8014df4f58c3f3d\",\"n\":64,\"dklen\":32,\"p\":1,\"r\":8},\"mac\":\"173ff105a9e7d950476b5f48d3f5f7a3884018c7972532141ab0ff0ecf5942b8\"},\"x-ethers\":{\"client\":\"ethers/6.13.2\",\"gethFilename\":\"UTC--2024-09-03T09-54-21.0Z--472cad6bbd47afd1cdfded81171112a452b4d441\",\"path\":\"m/44'/60'/0'/0/0\",\"locale\":\"en\",\"mnemonicCounter\":\"f9fcea424818dce89624a99d506dc6c4\",\"mnemonicCiphertext\":\"628b20780fcfebe9a6118630d92e52cb\",\"version\":\"0.1\"}}", + "core:wallet:discoveredOnce": true, + "core:wallet:lastUsedAccountByNetwork": { + "sepolia-alpha": { + "address": "0x1060ea20f2778571e616a803bb54f8546af4a067d9fcd44c9e9543b418d1001", + "cairoVersion": "1", + "classHash": "0x06e150953b26271a740bf2b6e9bca17cc52c68d765f761295de51ceb8526ee72", + "id": "0x01060ea20f2778571e616a803bb54f8546af4a067d9fcd44c9e9543b418d1001::sepolia-alpha::local_secret::1", + "index": 1, + "name": "Multisig 2", + "needsDeploy": false, + "network": { + "accountClassHash": { + "multisig": "0x6e150953b26271a740bf2b6e9bca17cc52c68d765f761295de51ceb8526ee72", + "smart": "0x036078334509b514626504edc9fb252328d1a240e4e948bef8d0c08dff45927f", + "standard": "0x036078334509b514626504edc9fb252328d1a240e4e948bef8d0c08dff45927f", + "standardCairo0": "0x033434ad846cdd5f23eb73ff09fe6fddd568284a0fb7d1be20ee482f044dabe2" + }, + "chainId": "SN_SEPOLIA", + "explorerUrl": "https://sepolia.voyager.online", + "id": "sepolia-alpha", + "l1ExplorerUrl": "https://sepolia.etherscan.io", + "multicallAddress": "0x05754af3760f3356da99aea5c3ec39ccac7783d925a19666ebbeca58ff0087f4", + "name": "Sepolia", + "possibleFeeTokenAddresses": [ + "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + "0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d" + ], + "readonly": true, + "rpcUrl": "https://api.hydrogen.argent47.net/v1/starknet/sepolia/rpc/v0.7" + }, + "networkId": "sepolia-alpha", + "signer": { + "derivationPath": "m/2645'/1195502025'/1148870696'/1'/0'/44", + "type": "ledger" + }, + "type": "multisig" + } + }, + "core:wallet:selected": { + "address": "0x1060ea20f2778571e616a803bb54f8546af4a067d9fcd44c9e9543b418d1001", + "id": "0x01060ea20f2778571e616a803bb54f8546af4a067d9fcd44c9e9543b418d1001::sepolia-alpha::local_secret::1", + "networkId": "sepolia-alpha" + }, + "repository:allNetworksWithStatus": [ + { + "id": "mainnet-alpha", + "status": "green" + }, + { + "id": "sepolia-alpha", + "status": "green" + }, + { + "id": "localhost", + "status": "unknown" + }, + { + "id": "integration", + "status": "unknown" + } + ], + "repository:core:multisig:baseWalletAccounts": [ + { + "address": "0x51cda0876cb58b05d9a6d9da52b00b8d04733028def56668867e3bffd9fac47", + "creator": "0x051cda0876cb58b05d9a6d9da52b00b8d04733028def56668867e3bffd9fac47", + "id": "0x051cda0876cb58b05d9a6d9da52b00b8d04733028def56668867e3bffd9fac47::sepolia-alpha::ledger::43", + "index": 0, + "networkId": "sepolia-alpha", + "publicKey": "0x00826a255157914857abb3619d8930a8f43c88923dface0583c81c7289f52b31", + "signers": [ + "0x01fbb2e03e41d28b0d572d48c2ef4bb1d2686f37f702e26f179c5101e0587048", + "0x00826a255157914857abb3619d8930a8f43c88923dface0583c81c7289f52b31" + ], + "threshold": 2, + "updatedAt": 1725359181004 + }, + { + "address": "0x1060ea20f2778571e616a803bb54f8546af4a067d9fcd44c9e9543b418d1001", + "cairoVersion": "1", + "classHash": "0x06e150953b26271a740bf2b6e9bca17cc52c68d765f761295de51ceb8526ee72", + "creator": "0x01060ea20f2778571e616a803bb54f8546af4a067d9fcd44c9e9543b418d1001", + "id": "0x01060ea20f2778571e616a803bb54f8546af4a067d9fcd44c9e9543b418d1001::sepolia-alpha::local_secret::1", + "index": 1, + "name": "Multisig 2", + "needsDeploy": false, + "network": { + "accountClassHash": { + "multisig": "0x6e150953b26271a740bf2b6e9bca17cc52c68d765f761295de51ceb8526ee72", + "smart": "0x036078334509b514626504edc9fb252328d1a240e4e948bef8d0c08dff45927f", + "standard": "0x036078334509b514626504edc9fb252328d1a240e4e948bef8d0c08dff45927f", + "standardCairo0": "0x033434ad846cdd5f23eb73ff09fe6fddd568284a0fb7d1be20ee482f044dabe2" + }, + "chainId": "SN_SEPOLIA", + "explorerUrl": "https://sepolia.voyager.online", + "id": "sepolia-alpha", + "l1ExplorerUrl": "https://sepolia.etherscan.io", + "multicallAddress": "0x05754af3760f3356da99aea5c3ec39ccac7783d925a19666ebbeca58ff0087f4", + "name": "Sepolia", + "possibleFeeTokenAddresses": [ + "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + "0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d" + ], + "readonly": true, + "rpcUrl": "https://api.hydrogen.argent47.net/v1/starknet/sepolia/rpc/v0.7" + }, + "networkId": "sepolia-alpha", + "publicKey": "0x0674870dedc85ff355200bab2d00c145f42d7d67d790b0f479dbdd2a42d55fbf", + "signer": { + "derivationPath": "m/44'/9004'/1'/0/1", + "type": "local_secret" + }, + "signers": [ + "0x0674870dedc85ff355200bab2d00c145f42d7d67d790b0f479dbdd2a42d55fbf", + "0x04f7808289a0cec299263ffa85d2c929b823d8849db6994173865f7208a907cd" + ], + "threshold": 2, + "type": "multisig", + "updatedAt": 1725359181005 + } + ], + "repository:core:multisig:signerNames": [ + { + "multisigPublicKey": "0x00826a255157914857abb3619d8930a8f43c88923dface0583c81c7289f52b31", + "signers": [] + }, + { + "multisigPublicKey": "0x0674870dedc85ff355200bab2d00c145f42d7d67d790b0f479dbdd2a42d55fbf", + "signers": [] + } + ], + "repository:knownDapps_v1": [ + { + "argentVerified": true, + "brandColor": "#2AAAFD", + "categories": ["DeFi"], + "contracts": [ + { + "address": "0x02bcc885342ebbcbcd170ae6cafa8a4bed22bb993479f49806e72d96af94c965", + "chain": "starknet" + } + ], + "dappId": "b39c1e1c-f4be-4344-afae-3792ccdc3977", + "dappUrl": "https://app.testnet.jediswap.xyz/#/swap", + "description": "A community-led fully permissionless and composable AMM on Starknet.", + "executeFromOutsideAllowed": false, + "inAppBrowserCompatible": false, + "links": [ + { + "name": "website", + "position": 1, + "url": "https://jediswap.xyz/" + }, + { + "name": "twitter", + "position": 2, + "url": "https://twitter.com/jediswap" + }, + { + "name": "discord", + "position": 3, + "url": "https://discord.gg/jediswap" + } + ], + "logoUrl": "https://www.dappland.com/dapps/jediswap/dapp-icon-jediswap.png", + "name": "JediSwap", + "supportedApps": [] + }, + { + "argentVerified": true, + "categories": [], + "contracts": [], + "dappId": "b13b2bf5-84a2-4bd5-963d-993f37571204", + "dappUrl": "https://argent.xyz", + "description": "Argent smartwallet on Mobile, Browser and Web. ", + "executeFromOutsideAllowed": false, + "inAppBrowserCompatible": false, + "links": [ + { + "name": "twitter", + "position": 1, + "url": "https://twitter.com/argent_hq" + }, + { + "name": "website", + "position": 2, + "url": "https://argent.xyz" + } + ], + "logoUrl": "https://static.hydrogen.argent47.net/dapp/logos/b13b2bf5-84a2-4bd5-963d-993f37571204.png", + "name": "Argent", + "supportedApps": [] + }, + { + "argentVerified": true, + "categories": ["NFTs"], + "contracts": [ + { + "address": "0x006fcf30a53fdc33c85ab428d6c481c5d241f1de403009c4e5b66aeaf3edc890", + "chain": "starknet" + } + ], + "dappId": "7b2395c1-6427-4ab2-b918-5817e34622f1", + "dappUrl": "https://testnet.aspect.co/", + "description": "An NFT marketplace", + "executeFromOutsideAllowed": false, + "inAppBrowserCompatible": false, + "links": [], + "logoUrl": "https://static.hydrogen.argent47.net/dapp/logos/7b2395c1-6427-4ab2-b918-5817e34622f1.webp", + "name": "Aspect", + "supportedApps": [] + }, + { + "argentVerified": false, + "categories": ["NFTs"], + "contracts": [], + "dappId": "17999edd-ae8b-48b1-9fbe-f76f1d316035", + "dappUrl": "https://mintsquare.io/starknet-testnet", + "description": "Mint Square is an NFT Marketplace on Ethereum L2 ZK Rollups", + "executeFromOutsideAllowed": false, + "inAppBrowserCompatible": true, + "links": [], + "logoUrl": "https://static.hydrogen.argent47.net/dapp/logos/17999edd-ae8b-48b1-9fbe-f76f1d316035.webp", + "name": "Mint Square", + "supportedApps": [] + }, + { + "argentVerified": false, + "categories": ["Games"], + "contracts": [], + "dappId": "30e29907-b467-46f1-8aef-c372053bbb07", + "description": "Decentralized strategy game", + "executeFromOutsideAllowed": false, + "inAppBrowserCompatible": false, + "links": [ + { + "name": "website", + "position": 1, + "url": "https://eykar.org/" + }, + { + "name": "twitter", + "position": 2, + "url": "https://twitter.com/AgeOfEykar" + }, + { + "name": "discord", + "position": 3, + "url": "https://discord.gg/rpksM5uCk5" + } + ], + "logoUrl": "https://www.dappland.com/dapps/ageofeykar/dapp-icon-age-of-eykar.png", + "name": "Age of Eykar" + }, + { + "argentVerified": false, + "categories": ["NFTs"], + "contracts": [ + { + "address": "0x07d4dc2bf13ede97b9e458dc401d4ff6dd386a02049de879ebe637af8299f91d", + "chain": "starknet" + } + ], + "dappId": "bf9ddb84-9ced-4fd0-b947-8da9c42ef3c5", + "description": "Pick a market, pick a date, and mint a day in crypto", + "executeFromOutsideAllowed": false, + "inAppBrowserCompatible": false, + "links": [ + { + "name": "website", + "position": 1, + "url": "https://www.almanacnft.xyz/" + }, + { + "name": "twitter", + "position": 2, + "url": "https://twitter.com/almanacNFT" + } + ], + "logoUrl": "https://www.dappland.com/dapps/almanacnft/almanacnft-logo.png", + "name": "AlmanacNFT" + }, + { + "argentVerified": false, + "categories": ["DeFi"], + "contracts": [], + "dappId": "cfe00a6c-82b1-4636-af28-18889ca2f63a", + "description": "Autonomous and dynamic synthetic issuance platform", + "executeFromOutsideAllowed": false, + "inAppBrowserCompatible": false, + "links": [ + { + "name": "website", + "position": 1, + "url": "https://www.auraprotocol.com/" + }, + { + "name": "twitter", + "position": 2, + "url": "https://twitter.com/AuraProtocol" + } + ], + "logoUrl": "https://www.dappland.com/dapps/aura/aura-logo.png", + "name": "Aura" + }, + { + "argentVerified": false, + "categories": ["Onramps", "DeFi", "Bridges"], + "contracts": [], + "dappId": "a438c763-3c6a-4b0f-9a8a-997d0798aee8", + "description": "The leading global on & off-ramp solution", + "executeFromOutsideAllowed": false, + "inAppBrowserCompatible": false, + "links": [ + { + "name": "website", + "position": 1, + "url": "https://www.banxa.com/" + }, + { + "name": "twitter", + "position": 2, + "url": "https://twitter.com/BanxaOfficial" + } + ], + "logoUrl": "https://www.dappland.com/dapps/banxa/dapp-icon-banxa.png", + "name": "Banxa" + }, + { + "argentVerified": false, + "brandColor": "#3BC6A5", + "categories": ["DeFi", "Infrastructure"], + "contracts": [ + { + "address": "0x07a6f98c03379b9513ca84cca1373ff452a7462a3b61598f0af5bb27ad7f76d1", + "chain": "starknet" + }, + { + "address": "0x01c0a36e26a8f822e0d81f20a5a562b16a8f8a3dfd99801367dd2aea8f1a87a2", + "chain": "starknet" + }, + { + "address": "0x0231adde42526bad434ca2eb983efdd64472638702f87f97e6e3c084f264e06f", + "chain": "starknet" + }, + { + "address": "0x00975910cd99bc56bd289eaaa5cee6cd557f0ddafdb2ce6ebea15b158eb2c664", + "chain": "starknet" + } + ], + "dappId": "d1a13a03-4402-412e-a10c-a618f5e0e0b2", + "dappUrl": "https://10kswap.com", + "description": "A Layer2 AMM protocol", + "executeFromOutsideAllowed": false, + "inAppBrowserCompatible": false, + "links": [ + { + "name": "website", + "position": 1, + "url": "https://10kswap.com/" + }, + { + "name": "twitter", + "position": 2, + "url": "https://twitter.com/10KSwap" + }, + { + "name": "discord", + "position": 3, + "url": "https://discord.gg/T77yphUPB6" + } + ], + "logoUrl": "https://www.dappland.com/dapps/10kswap/dapp-icon-10kswap.png", + "name": "10KSwap", + "supportedApps": [] + }, + { + "argentVerified": false, + "categories": ["NFTs", "Games"], + "contracts": [ + { + "address": "0x01435498bf393da86b4733b9264a86b58a42b31f8d8b8ba309593e5c17847672", + "chain": "starknet" + }, + { + "address": "0x01e1f972637ad02e0eed03b69304344c4253804e528e1a5dd5c26bb2f23a8139", + "chain": "starknet" + }, + { + "address": "0x05faa82e2aec811d3a3b14c1f32e9bbb6c9b4fd0cd6b29a823c98c7360019aa4", + "chain": "starknet" + } + ], + "dappId": "51fb3de4-a3b8-4fa5-b293-6e8a48c0e81e", + "dappUrl": "https://briq.construction/", + "description": "The NFT building protocol", + "executeFromOutsideAllowed": false, + "inAppBrowserCompatible": false, + "links": [ + { + "name": "website", + "position": 1, + "url": "https://briq.construction/" + }, + { + "name": "twitter", + "position": 2, + "url": "https://twitter.com/briqNFT" + }, + { + "name": "discord", + "position": 3, + "url": "https://discord.gg/kpvbDCw5pr" + } + ], + "logoUrl": "https://www.dappland.com/dapps/briq/dapp-icon-briq.png", + "name": "briq", + "supportedApps": [] + }, + { + "argentVerified": false, + "categories": ["DeFi", "NFTs"], + "contracts": [ + { + "address": "0x061bcc33b0469cd072ad47813a1efd250fd36a28425d774c31c8f33c87306e8e", + "chain": "starknet" + }, + { + "address": "0x04047810e4f759336f941a16b6de9d8d2f934e976b9a9431a2964646df9025c6", + "chain": "starknet" + }, + { + "address": "0x00ebf4bbab9c934fa0212c9358331d4a9543ad66a7e0007d4a720cfa6b56061e", + "chain": "starknet" + } + ], + "dappId": "64412379-c884-4f0a-ac93-8f32635381cf", + "description": "Invest in decarbonization", + "executeFromOutsideAllowed": false, + "inAppBrowserCompatible": false, + "links": [ + { + "name": "website", + "position": 1, + "url": "https://carbonable.io/" + }, + { + "name": "twitter", + "position": 2, + "url": "https://twitter.com/Carbonable_io" + }, + { + "name": "discord", + "position": 3, + "url": "https://discord.gg/Huspzn4teW" + } + ], + "logoUrl": "https://www.dappland.com/dapps/carbonable/carbonable-icon.png", + "name": "Carbonable" + }, + { + "argentVerified": false, + "categories": ["DeFi"], + "contracts": [ + { + "address": "0x076dbabc4293db346b0a56b29b6ea9fe18e93742c73f12348c8747ecfc1050aa", + "chain": "starknet" + }, + { + "address": "0x001405ab78ab6ec90fba09e6116f373cda53b0ba557789a4578d8c1ec374ba0f", + "chain": "starknet" + } + ], + "dappId": "43cadb59-b2f6-4681-b51c-d5bf61bae7a9", + "description": "DeFi Options", + "executeFromOutsideAllowed": false, + "inAppBrowserCompatible": false, + "links": [ + { + "name": "website", + "position": 1, + "url": "https://carmine.finance/" + }, + { + "name": "twitter", + "position": 2, + "url": "https://twitter.com/CarmineOptions" + }, + { + "name": "discord", + "position": 3, + "url": "https://discord.gg/6J6NpPxmp6" + } + ], + "logoUrl": "https://www.dappland.com/dapps/carminefinance/carmine-logo.webp", + "name": "Carmine Options AMM" + }, + { + "argentVerified": false, + "categories": ["Games"], + "contracts": [], + "dappId": "04397d52-ed91-41e3-812f-528a2823c268", + "description": "Starknet Gaming Console", + "executeFromOutsideAllowed": false, + "inAppBrowserCompatible": false, + "links": [ + { + "name": "website", + "position": 1, + "url": "https://cartridge.gg/" + }, + { + "name": "twitter", + "position": 2, + "url": "https://twitter.com/cartridge_gg" + }, + { + "name": "discord", + "position": 3, + "url": "https://discord.gg/cartridge" + } + ], + "logoUrl": "https://www.dappland.com/dapps/cartridge/logo.jpeg", + "name": "Cartridge" + }, + { + "argentVerified": false, + "categories": ["Infrastructure"], + "contracts": [], + "dappId": "a1b9afec-d5cd-4580-b422-c980cf4a2e5a", + "description": "From events to Api", + "executeFromOutsideAllowed": false, + "inAppBrowserCompatible": false, + "links": [ + { + "name": "website", + "position": 1, + "url": "https://checkpoint.fyi" + }, + { + "name": "twitter", + "position": 2, + "url": "https://twitter.com/checkpointfyi" + }, + { + "name": "discord", + "position": 3, + "url": "https://discord.gg/QJupGWJbge" + } + ], + "logoUrl": "https://www.dappland.com/dapps/checkpoint/dapp-icon-checkpoint.png", + "name": "Checkpoint" + }, + { + "argentVerified": false, + "categories": ["Infrastructure"], + "contracts": [], + "dappId": "5f752fba-a4e2-47ce-8297-5aa4b2c598f1", + "description": "Ganache for Starknet", + "executeFromOutsideAllowed": false, + "inAppBrowserCompatible": false, + "links": [ + { + "name": "website", + "position": 1, + "url": "https://shard-labs.github.io/starknet-devnet/" + }, + { + "name": "twitter", + "position": 2, + "url": "https://twitter.com/0xSpaceShard" + } + ], + "logoUrl": "https://www.dappland.com/dapps/spaceshard/320x320-devnet.png", + "name": "Starknet Devnet" + }, + { + "argentVerified": false, + "categories": ["DAOs", "DeFi"], + "contracts": [], + "dappId": "e1af90cc-f4b0-4d17-8f33-f45c2484dc8f", + "description": "DAO Based Ecosystem Catalyst for projects built on Starknet", + "executeFromOutsideAllowed": false, + "inAppBrowserCompatible": false, + "links": [ + { + "name": "website", + "position": 1, + "url": "https://dolvenlabs.com/" + }, + { + "name": "twitter", + "position": 2, + "url": "https://twitter.com/dolvenlabs" + }, + { + "name": "discord", + "position": 3, + "url": "https://discord.gg/invite/UG5tkPa3xn" + } + ], + "logoUrl": "https://www.dappland.com/dapps/dolvenlabs/dapp-icon-dolven.png", + "name": "Dolven Labs" + }, + { + "argentVerified": false, + "categories": ["Infrastructure"], + "contracts": [], + "dappId": "fbcf0e9e-7fda-497e-a291-ea8a4c6c8964", + "description": "Your multi-chain web3 auth platform", + "executeFromOutsideAllowed": false, + "inAppBrowserCompatible": false, + "links": [ + { + "name": "website", + "position": 1, + "url": "https://www.dynamic.xyz/" + }, + { + "name": "twitter", + "position": 2, + "url": "https://twitter.com/dynamic_xyz" + } + ], + "logoUrl": "https://www.dappland.com/dapps/dynamic/dapp-logo.jpg", + "name": "Dynamic" + }, + { + "argentVerified": false, + "categories": ["DeFi"], + "contracts": [ + { + "address": "0x00dc1138a03d241b87a2f940db070e47f937930e2a0337f6e6ee1a97883009e7", + "chain": "starknet" + } + ], + "dappId": "0e966831-4120-470e-8031-022e77e1fa12", + "description": "Gathers all AMMs of Starknet at one place.", + "executeFromOutsideAllowed": false, + "inAppBrowserCompatible": false, + "links": [ + { + "name": "website", + "position": 1, + "url": "https://fibrous.finance/" + }, + { + "name": "twitter", + "position": 2, + "url": "https://twitter.com/FibrousFinance" + } + ], + "logoUrl": "https://www.dappland.com/dapps/fibrousfinance/dapp-icon-fibrous.png", + "name": "Fibrous Finance" + }, + { + "argentVerified": false, + "categories": ["NFTs", "Games"], + "contracts": [], + "dappId": "f74d6f2e-f222-4b53-a9af-6035cf30982e", + "description": "World builder game to create your dream community", + "executeFromOutsideAllowed": false, + "inAppBrowserCompatible": false, + "links": [ + { + "name": "website", + "position": 1, + "url": "https://www.frenslands.xyz/" + }, + { + "name": "twitter", + "position": 2, + "url": "https://twitter.com/FrensLands" + }, + { + "name": "discord", + "position": 3, + "url": "https://discord.gg/gehYZU9Trf" + } + ], + "logoUrl": "https://www.dappland.com/dapps/frenslands/Press_Logo_TrueScale.png", + "name": "Frens Lands" + }, + { + "argentVerified": false, + "categories": ["Games"], + "contracts": [ + { + "address": "0x06a05844a03bb9e744479e3298f54705a35966ab04140d3d8dd797c1f6dc49d0", + "chain": "starknet" + } + ], + "dappId": "dbb4647b-f6ac-4bc4-b718-20c7b114775c", + "description": "Experiment in Layer 2 gaming", + "executeFromOutsideAllowed": false, + "inAppBrowserCompatible": false, + "links": [ + { + "name": "website", + "position": 1, + "url": "https://www.gol2.io/" + }, + { + "name": "twitter", + "position": 2, + "url": "https://twitter.com/GoL2io" + } + ], + "logoUrl": "https://www.dappland.com/dapps/gol2/dapp-icon-gol2.png", + "name": "GoL2" + }, + { + "argentVerified": false, + "categories": ["Infrastructure"], + "contracts": [], + "dappId": "886a788b-23b4-4bfc-a743-81c7fbcacd18", + "description": "Use Hardhat on Starknet", + "executeFromOutsideAllowed": false, + "inAppBrowserCompatible": false, + "links": [ + { + "name": "website", + "position": 1, + "url": "https://www.spaceshard.io" + }, + { + "name": "twitter", + "position": 2, + "url": "https://twitter.com/0xSpaceShard" + } + ], + "logoUrl": "https://www.dappland.com/dapps/spaceshard/320x320-hh.png", + "name": "Starknet Hardhat Plugin" + }, + { + "argentVerified": false, + "categories": ["DeFi"], + "contracts": [], + "dappId": "56d0b254-c6e7-4bc2-986b-abf4dce7950b", + "description": "Permissionless undercollateralized loans", + "executeFromOutsideAllowed": false, + "inAppBrowserCompatible": false, + "links": [ + { + "name": "website", + "position": 1, + "url": "https://hashstack.finance/" + }, + { + "name": "twitter", + "position": 2, + "url": "https://twitter.com/0xHashstack" + }, + { + "name": "discord", + "position": 3, + "url": "https://discord.gg/hashstack-community-907151419650482217" + } + ], + "logoUrl": "https://www.dappland.com/dapps/hashstack/hashstack-logo.png", + "name": "Hashstack" + }, + { + "argentVerified": false, + "categories": ["Infrastructure"], + "contracts": [], + "dappId": "5c799100-ea4b-415c-9f74-066fa8eb753f", + "description": "Storage Proofs API", + "executeFromOutsideAllowed": false, + "inAppBrowserCompatible": false, + "links": [ + { + "name": "website", + "position": 1, + "url": "https://herodotus.dev/" + }, + { + "name": "twitter", + "position": 2, + "url": "https://twitter.com/HerodotusDev" + }, + { + "name": "discord", + "position": 3, + "url": "https://discord.gg/t6p4qs4PBw" + } + ], + "logoUrl": "https://www.dappland.com/dapps/herodotus/herodotus-logo.png", + "name": "Herodotus" + }, + { + "argentVerified": false, + "categories": ["Games", "NFTs"], + "contracts": [], + "dappId": "4a6f0f47-4d2d-4551-b240-2f6e2d67a2e5", + "description": "A Conquer and Earn NFT game", + "executeFromOutsideAllowed": false, + "inAppBrowserCompatible": false, + "links": [ + { + "name": "website", + "position": 1, + "url": "https://imperiumwars.xyz/" + }, + { + "name": "twitter", + "position": 2, + "url": "https://twitter.com/ImperiumWars" + }, + { + "name": "discord", + "position": 3, + "url": "https://discord.gg/VqXSJaeFK4" + } + ], + "logoUrl": "https://www.dappland.com/dapps/imperiumwars/4mQEWxX8_400x400.jpg", + "name": "Imperium Wars" + }, + { + "argentVerified": false, + "categories": [], + "contracts": [], + "dappId": "784725ea-dd35-40db-b2b3-1b2fbf30438d", + "dappUrl": "http://www.google.com", + "description": "Test telegram dapp", + "executeFromOutsideAllowed": false, + "inAppBrowserCompatible": false, + "links": [ + { + "name": "website", + "position": 1, + "url": "https://www.google.com" + } + ], + "name": "Chinese Dice Test Dapp", + "sessionConfig": { + "allowList": [ + { + "contractAddress": "0x04416913d018f639e58fd54d9344ea020534692a9cbe1c2eba191c8b2d2682e5", + "methods": ["propose_game"] + } + ], + "maxExpiryDays": 31 + }, + "supportedApps": [] + }, + { + "argentVerified": false, + "categories": ["Onramps", "Bridges"], + "contracts": [], + "dappId": "8d23e708-ee10-423e-bd9a-07141ddbb3c6", + "description": "CEX <> L2 Gateway", + "executeFromOutsideAllowed": false, + "inAppBrowserCompatible": false, + "links": [ + { + "name": "website", + "position": 1, + "url": "https://layerswap.io/" + }, + { + "name": "twitter", + "position": 2, + "url": "https://twitter.com/layerswap" + }, + { + "name": "discord", + "position": 3, + "url": "https://discord.gg/RUQDbPGTFb" + } + ], + "logoUrl": "https://www.dappland.com/dapps/layerswap/dapp-icon-layerswap.png", + "name": "Layerswap" + }, + { + "argentVerified": false, + "categories": ["DAOs"], + "contracts": [], + "dappId": "76823afb-6ede-46da-bf45-b5ec5fe74a3d", + "description": "DAO to ignite and fuel fully on-chain games", + "executeFromOutsideAllowed": false, + "inAppBrowserCompatible": false, + "links": [ + { + "name": "website", + "position": 1, + "url": "https://matchboxdao.com/" + }, + { + "name": "twitter", + "position": 2, + "url": "https://twitter.com/matchbox_dao" + }, + { + "name": "discord", + "position": 3, + "url": "https://discord.gg/g3GRRngN" + } + ], + "logoUrl": "https://www.dappland.com/dapps/matchboxdao/dapp-icon-matchbox.png", + "name": "MatchBoxDAO" + }, + { + "argentVerified": false, + "categories": ["DeFi", "Infrastructure", "Tool"], + "contracts": [], + "dappId": "247cf3e4-0d70-474b-9f48-74b3de9c363a", + "description": "Boost your DeFi journey", + "executeFromOutsideAllowed": false, + "inAppBrowserCompatible": false, + "links": [ + { + "name": "website", + "position": 1, + "url": "https://morphinefi.xyz/" + }, + { + "name": "twitter", + "position": 2, + "url": "https://twitter.com/MorphineFi" + }, + { + "name": "discord", + "position": 3, + "url": "https://discord.gg/hJupqTrK4G" + } + ], + "logoUrl": "https://www.dappland.com/dapps/morphinefi/dapp-icon-morphinefi.jpeg", + "name": "MorphineFi" + }, + { + "argentVerified": false, + "categories": ["Social"], + "contracts": [], + "dappId": "d3cdb69b-2eac-416f-8665-ba06ae329349", + "description": "Contribute to the best projects on Starknet", + "executeFromOutsideAllowed": false, + "inAppBrowserCompatible": false, + "links": [ + { + "name": "website", + "position": 1, + "url": "https://www.onlydust.xyz/" + }, + { + "name": "twitter", + "position": 2, + "url": "https://twitter.com/OnlyDust_xyz" + } + ], + "logoUrl": "https://www.dappland.com/dapps/onlydust/dapp-icon-onlydust.png", + "name": "Only Dust" + }, + { + "argentVerified": false, + "categories": ["Bridges"], + "contracts": [], + "dappId": "bbe887dc-65a7-45ef-9c18-f12dc5e16fb3", + "description": "Decentralized cross-rollup bridge", + "executeFromOutsideAllowed": false, + "inAppBrowserCompatible": false, + "links": [ + { + "name": "website", + "position": 1, + "url": "https://www.orbiter.finance/" + }, + { + "name": "twitter", + "position": 2, + "url": "https://twitter.com/Orbiter_Finance" + }, + { + "name": "discord", + "position": 3, + "url": "https://discord.gg/hJJvXP7C73" + } + ], + "logoUrl": "https://www.dappland.com/dapps/orbiterfinance/dapp-icon-orbiter.png", + "name": "Orbiter.Finance" + }, + { + "argentVerified": false, + "categories": ["Infrastructure"], + "contracts": [ + { + "address": "0x04746485fa57b49dc992c35d7f12054b5a7d24b0e187021cd8f40bc2517700bc", + "chain": "starknet" + }, + { + "address": "0x0346c57f094d641ad94e43468628d8e9c574dcb2803ec372576ccc60a40be2c4", + "chain": "starknet" + } + ], + "dappId": "5f3804be-884a-4207-bdc3-cac88290ea62", + "description": "Decentralized & composable data infrastructure", + "executeFromOutsideAllowed": false, + "inAppBrowserCompatible": false, + "links": [ + { + "name": "website", + "position": 1, + "url": "https://pragmaoracle.com/" + }, + { + "name": "twitter", + "position": 2, + "url": "https://twitter.com/PragmaOracle" + }, + { + "name": "discord", + "position": 3, + "url": "https://discord.gg/N7sM7VzfJB" + } + ], + "logoUrl": "https://www.dappland.com/dapps/pragma/dapp-icon-pragma.png", + "name": "Pragma" + }, + { + "argentVerified": false, + "categories": ["Games", "NFTs"], + "contracts": [ + { + "address": "0x07f87b400e9cc3f56a3eb26a924a46a053ebae4eea9ba85f58c17f7a09331aa1", + "chain": "starknet" + }, + { + "address": "0x0035345c560052c8d14dcbde85114c123fc3c65b7b5fab2d045fb3a7e57df453", + "chain": "starknet" + } + ], + "dappId": "c9cc6ab2-b693-479a-89a8-07c4eaa55cac", + "description": "Collaborative artworks", + "executeFromOutsideAllowed": false, + "inAppBrowserCompatible": false, + "links": [ + { + "name": "website", + "position": 1, + "url": "https://pxls.wtf/" + }, + { + "name": "twitter", + "position": 2, + "url": "https://twitter.com/PxlsWtf" + }, + { + "name": "discord", + "position": 3, + "url": "https://discord.gg/ufafywMTQh" + } + ], + "logoUrl": "https://www.dappland.com/dapps/pxls/dapp-icon-pxls.png", + "name": "Pxls" + }, + { + "argentVerified": false, + "categories": ["DeFi"], + "contracts": [], + "dappId": "143d6933-8da1-4242-97db-50a817a91797", + "description": "Perpetuals Trading.", + "executeFromOutsideAllowed": false, + "inAppBrowserCompatible": false, + "links": [ + { + "name": "website", + "position": 1, + "url": "https://rabbitx.io/" + }, + { + "name": "twitter", + "position": 2, + "url": "https://twitter.com/rabbitx_io" + }, + { + "name": "discord", + "position": 3, + "url": "https://discord.gg/rabbitx" + } + ], + "logoUrl": "https://www.dappland.com/dapps/rabbitx/dapp-icon-rabbitx.png", + "name": "RabbitX" + }, + { + "argentVerified": false, + "categories": ["Onramps"], + "contracts": [], + "dappId": "05b55732-ecd8-46b6-a835-cefb507bd383", + "description": "Safe and easy way to buy crypto", + "executeFromOutsideAllowed": false, + "inAppBrowserCompatible": false, + "links": [ + { + "name": "website", + "position": 1, + "url": "https://ramp.network/" + }, + { + "name": "twitter", + "position": 2, + "url": "https://twitter.com/RampNetwork" + }, + { + "name": "discord", + "position": 3, + "url": "https://discord.gg/vTwqTcsq" + } + ], + "logoUrl": "https://www.dappland.com/dapps/ramp/dapp-icon-ramp.png", + "name": "Ramp" + }, + { + "argentVerified": false, + "categories": ["Bridges", "DeFi", "Infrastructure"], + "contracts": [], + "dappId": "c9a919ed-2f9c-4498-9da9-7cc6334dca1a", + "description": "MultiChain Aggregator", + "executeFromOutsideAllowed": false, + "inAppBrowserCompatible": false, + "links": [ + { + "name": "website", + "position": 1, + "url": "https://app.rango.exchange" + }, + { + "name": "twitter", + "position": 2, + "url": "https://twitter.com/RangoExchange" + }, + { + "name": "discord", + "position": 3, + "url": "https://discord.gg/VmdCgxyKhr" + } + ], + "logoUrl": "https://www.dappland.com/dapps/rango/rango-logo.jpg", + "name": "Rango" + }, + { + "argentVerified": false, + "categories": ["Games", "NFTs", "DAOs"], + "contracts": [ + { + "address": "0x0000000000000000000000007afe30cb3e53dba6801aa0ea647a0ecea7cbe18d", + "chain": "starknet" + } + ], + "dappId": "40996777-91a5-46b4-926a-eedc7cde84a5", + "description": "Massive multiplayer on-chain game of economics and chivalry", + "executeFromOutsideAllowed": false, + "inAppBrowserCompatible": false, + "links": [ + { + "name": "website", + "position": 1, + "url": "https://realmseternum.com/" + }, + { + "name": "twitter", + "position": 2, + "url": "https://twitter.com/LootRealms" + }, + { + "name": "discord", + "position": 3, + "url": "https://discord.gg/RnC9VNRzZH" + } + ], + "logoUrl": "https://www.dappland.com/dapps/realms/dapp-icon-realms.jpeg", + "name": "Realms" + }, + { + "argentVerified": false, + "categories": ["Social", "DAOs", "Infrastructure"], + "contracts": [], + "dappId": "f4f244b3-26a1-4714-bee0-181b9ec0778e", + "description": "Analytics Data Hub", + "executeFromOutsideAllowed": false, + "inAppBrowserCompatible": false, + "links": [ + { + "name": "website", + "position": 1, + "url": "https://starkboard.io/" + }, + { + "name": "twitter", + "position": 2, + "url": "https://twitter.com/starkboard" + }, + { + "name": "discord", + "position": 3, + "url": "https://discord.gg/starkboard" + } + ], + "logoUrl": "https://www.dappland.com/dapps/starkboard/dapp-icon-starkboard.png", + "name": "StarkBoard" + }, + { + "argentVerified": false, + "categories": ["DeFi"], + "contracts": [], + "dappId": "f927f1da-b5ba-488d-ac82-6f645f407246", + "description": "The Portal to Starknet", + "executeFromOutsideAllowed": false, + "inAppBrowserCompatible": false, + "links": [ + { + "name": "website", + "position": 1, + "url": "https://www.starkdefi.com/" + }, + { + "name": "twitter", + "position": 2, + "url": "https://twitter.com/StarkDefi" + }, + { + "name": "discord", + "position": 3, + "url": "https://discord.gg/starkdefi" + } + ], + "logoUrl": "https://www.dappland.com/dapps/starkdefi/dapp-icon-starkdefi.png", + "name": "StarkDefi" + }, + { + "argentVerified": false, + "categories": ["DeFi", "Tool", "Infrastructure"], + "contracts": [], + "dappId": "b600070d-2572-41bd-a7a8-d696eba60a32", + "description": "Portfolio-Yield Tracker", + "executeFromOutsideAllowed": false, + "inAppBrowserCompatible": false, + "links": [ + { + "name": "website", + "position": 1, + "url": "https://starkendefi.xyz/" + }, + { + "name": "twitter", + "position": 2, + "url": "https://twitter.com/StarkenDefi" + }, + { + "name": "discord", + "position": 3, + "url": "https://discord.gg/hJupqTrK4G" + } + ], + "logoUrl": "https://www.dappland.com/dapps/starkenfi/dapp-icon-starkenfi.jpeg", + "name": "StarkenFi" + }, + { + "argentVerified": false, + "categories": ["NFTs", "Social"], + "contracts": [ + { + "address": "0x05dbdedc203e92749e2e746e2d40a768d966bd243df04a6b712e222bc040a9af", + "chain": "starknet" + }, + { + "address": "0x06ac597f8116f886fa1c97a23fa4e08299975ecaf6b598873ca6792b9bbfb678", + "chain": "starknet" + }, + { + "address": "0x02383504fa1365cf31921c1411e14ea45b6376e9a0da8890d51359fd05575f48", + "chain": "starknet" + } + ], + "dappId": "cd7cbb12-86cc-4e76-92c2-e140bd6a2167", + "description": "All in one identity service on starknet", + "executeFromOutsideAllowed": false, + "inAppBrowserCompatible": false, + "links": [ + { + "name": "website", + "position": 1, + "url": "https://starknet.id/" + }, + { + "name": "twitter", + "position": 2, + "url": "https://twitter.com/starknet_id" + }, + { + "name": "discord", + "position": 3, + "url": "https://discord.gg/8uS2Mgcsza" + } + ], + "logoUrl": "https://www.dappland.com/dapps/starknetid/logo.svg", + "name": "Starknet.id" + }, + { + "argentVerified": false, + "categories": ["Social", "NFTs"], + "contracts": [], + "dappId": "c5987ed2-65bd-41e6-bc05-75768424b25f", + "description": "Social Network Protocol on Starknet", + "executeFromOutsideAllowed": false, + "inAppBrowserCompatible": false, + "links": [ + { + "name": "website", + "position": 1, + "url": "https://starknet.social/" + }, + { + "name": "twitter", + "position": 2, + "url": "https://twitter.com/StarknetSocial" + }, + { + "name": "discord", + "position": 3, + "url": "https://discord.gg/invite/UG5tkPa3xn" + } + ], + "logoUrl": "https://www.dappland.com/dapps/starknetsocial/dapp-logo-sns.png", + "name": "Starknet Social" + }, + { + "argentVerified": false, + "categories": ["Infrastructure"], + "contracts": [], + "dappId": "e6466695-8dfd-49c8-a926-aa1a8df7997d", + "description": "Starknet block explorer", + "executeFromOutsideAllowed": false, + "inAppBrowserCompatible": false, + "links": [ + { + "name": "website", + "position": 1, + "url": "https://starkscan.co/" + }, + { + "name": "twitter", + "position": 2, + "url": "https://twitter.com/StarkscanCo" + }, + { + "name": "discord", + "position": 3, + "url": "https://discord.gg/Pdt4Wr6gx7" + } + ], + "logoUrl": "https://www.dappland.com/dapps/starkscan/dapp-icon-starkscan.png", + "name": "STARKSCAN" + }, + { + "argentVerified": false, + "categories": ["DeFi", "Infrastructure"], + "contracts": [ + { + "address": "0x028850a764600d53b2009b17428ae9eb980a4c4ea930a69ed8668048ef082a04", + "chain": "starknet" + }, + { + "address": "0x076a028b19d27310f5e9f941041ae4a3a52c0e0024d593ddcb0d34e1dcd24af1", + "chain": "starknet" + }, + { + "address": "0x071d48483dcfa86718a717f57cf99a72ff8198b4538a6edccd955312fe624747", + "chain": "starknet" + }, + { + "address": "0x04acd4b2a59eae7196f6a5c26ead8cb5f9d7ad3d911096418a23357bb2eac075", + "chain": "starknet" + }, + { + "address": "0x0419772c05f2db85b8c2b2f92e04ef4f66884270f4eb97718c8693f3b33492f5", + "chain": "starknet" + }, + { + "address": "0x0501c3c89faeecdd0f09d9d1c162caa401165f17dee32a224c6f6188fc16b7b1", + "chain": "starknet" + }, + { + "address": "0x00f3be869afa2abb57ab452c2659a2239cb4c83594902fd7f4d07d91316e9894", + "chain": "starknet" + } + ], + "dappId": "6fd49987-9613-4ce8-a4df-e7b74651d8e2", + "description": "The web3 spreadsheet", + "executeFromOutsideAllowed": false, + "inAppBrowserCompatible": false, + "links": [ + { + "name": "website", + "position": 1, + "url": "https://www.starksheet.xyz/" + }, + { + "name": "twitter", + "position": 2, + "url": "https://twitter.com/starksheet" + }, + { + "name": "discord", + "position": 3, + "url": "https://discord.gg/BtuD5HguDX" + } + ], + "logoUrl": "https://www.dappland.com/dapps/starksheet/dapp-icon-starksheet.png", + "name": "Starksheet" + }, + { + "argentVerified": false, + "categories": ["Games"], + "contracts": [], + "dappId": "7e4d78d7-6979-469a-94b1-c7750688995f", + "description": "Truly fun, secure and crypto-native games", + "executeFromOutsideAllowed": false, + "inAppBrowserCompatible": false, + "links": [ + { + "name": "website", + "position": 1, + "url": "https://ninth.gg/" + }, + { + "name": "twitter", + "position": 2, + "url": "https://twitter.com/ninth_gg" + }, + { + "name": "discord", + "position": 3, + "url": "https://discord.gg/mJeFBtdabx" + } + ], + "logoUrl": "https://www.dappland.com/dapps/theninth/dapp-icon-theninth.png", + "name": "The Ninth" + }, + { + "argentVerified": false, + "categories": ["Infrastructure"], + "contracts": [], + "dappId": "a4ca61bd-ed9a-4ecb-9a3c-06e8367e4357", + "description": "Starknet Block Explorer", + "executeFromOutsideAllowed": false, + "inAppBrowserCompatible": false, + "links": [ + { + "name": "website", + "position": 1, + "url": "https://v2.viewblock.io/starknet" + }, + { + "name": "twitter", + "position": 2, + "url": "https://twitter.com/ViewBlock" + } + ], + "logoUrl": "https://www.dappland.com/dapps/viewblock/logo.png", + "name": "ViewBlock" + }, + { + "argentVerified": false, + "categories": ["Infrastructure"], + "contracts": [], + "dappId": "2b7a2355-0db0-4aea-afdb-43ae952c39d0", + "description": "Starknet block explorer", + "executeFromOutsideAllowed": false, + "inAppBrowserCompatible": false, + "links": [ + { + "name": "website", + "position": 1, + "url": "https://voyager.online/" + }, + { + "name": "twitter", + "position": 2, + "url": "https://twitter.com/0xvoyageronline" + }, + { + "name": "discord", + "position": 3, + "url": "https://discord.gg/pkKQmuEw" + } + ], + "logoUrl": "https://www.dappland.com/dapps/voyager/dapp-icon-voyager.png", + "name": "Voyager" + }, + { + "argentVerified": false, + "categories": ["DeFi", "Infrastructure"], + "dappId": "b9119c15-c264-485f-871c-123aedccb458", + "description": "The Starknet Yield Aggregator", + "executeFromOutsideAllowed": false, + "inAppBrowserCompatible": false, + "links": [ + { + "name": "website", + "position": 1, + "url": "https://www.yagi.finance" + }, + { + "name": "twitter", + "position": 2, + "url": "https://twitter.com/yagi_fi" + }, + { + "name": "discord", + "position": 3, + "url": "https://discord.gg/49TYpjfkUn" + } + ], + "logoUrl": "https://www.dappland.com/dapps/yagi/dapp-icon-yagi.png", + "name": "Yagi Finance" + }, + { + "argentVerified": false, + "categories": ["DeFi", "DAOs"], + "contracts": [], + "dappId": "ab5ae474-b36f-4d64-9693-2a293e28082e", + "description": "The first perpetual futures exchange on Starknet", + "executeFromOutsideAllowed": false, + "inAppBrowserCompatible": false, + "links": [ + { + "name": "website", + "position": 1, + "url": "https://zkx.fi" + }, + { + "name": "twitter", + "position": 2, + "url": "https://twitter.com/zkxprotocol" + }, + { + "name": "discord", + "position": 3, + "url": "https://discord.gg/7YrNHdwNyu" + } + ], + "logoUrl": "https://www.dappland.com/dapps/zkx/zkx-icon.png", + "name": "ZKX" + }, + { + "argentVerified": false, + "categories": ["Social", "NFTs", "DAOs"], + "contracts": [ + { + "address": "0x0708e824e3b83b1e9b644b52b9b9281f949acca5516f65a5df37f19bdc846a5b", + "chain": "starknet" + } + ], + "dappId": "1143a83d-2ea0-4dcf-82a3-ae0982eb7383", + "dappUrl": "https://earlystarkers.io", + "description": "1234 Star NFTs on the sky-map enable unique utilities and prove you are an Early Adopter on Starknet", + "executeFromOutsideAllowed": false, + "inAppBrowserCompatible": false, + "links": [ + { + "name": "website", + "position": 1, + "url": "https://earlystarkers.io" + }, + { + "name": "twitter", + "position": 2, + "url": "https://twitter.com/earlystarkers" + } + ], + "logoUrl": "https://www.dappland.com/dapps/earlystarkers/dapp-logo-earlystarkers.png", + "name": "Early Starkers", + "supportedApps": [] + }, + { + "argentVerified": false, + "categories": ["Bridges"], + "contracts": [], + "dappId": "d717dc5d-a104-47ee-9ca2-55e4f9c0275a", + "dappUrl": "https://starkgate.starknet.io/", + "description": "Ethereum <> Starknet Bridge", + "executeFromOutsideAllowed": false, + "inAppBrowserCompatible": false, + "links": [ + { + "name": "website", + "position": 1, + "url": "https://starkgate.starknet.io/" + }, + { + "name": "twitter", + "position": 2, + "url": "https://twitter.com/Starknet_Intern" + }, + { + "name": "discord", + "position": 3, + "url": "https://discord.gg/qypnmzkhbc" + } + ], + "logoUrl": "https://www.dappland.com/dapps/starkgate/dapp-icon-starkgate.png", + "name": "StarkGate", + "sessionConfig": { + "allowList": [ + { + "contractAddress": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + "methods": ["transfer"] + } + ], + "maxExpiryDays": 7 + }, + "supportedApps": [] + }, + { + "argentVerified": false, + "brandColor": "#1B2395", + "categories": ["DeFi"], + "contracts": [], + "dappId": "d5a32e2e-1c14-4308-ac5d-1aaeb8d40868", + "description": "Money-market protocol", + "executeFromOutsideAllowed": false, + "inAppBrowserCompatible": false, + "links": [ + { + "name": "website", + "position": 1, + "url": "https://zklend.com/" + }, + { + "name": "twitter", + "position": 2, + "url": "https://twitter.com/zkLend" + }, + { + "name": "discord", + "position": 3, + "url": "https://discord.gg/3v7RhwtJ8S" + } + ], + "logoUrl": "https://www.dappland.com/dapps/zklend/zklend-logo.png", + "name": "zkLend" + }, + { + "argentVerified": true, + "categories": [], + "contracts": [ + { + "address": "0x0563cd683faa47e7d5fc1639958e6e8ec139d1911af65e97b4fdf1913e66fbbd", + "chain": "starknet" + } + ], + "dappId": "e9998c6c-6d99-446b-9f48-fd0cec819298", + "dappUrl": "https://spok.hydrogen.argent47.net/", + "description": "Complete tasks, collect SPOKs and gain kudos in the Starknet community! Built by Argent.", + "executeFromOutsideAllowed": false, + "inAppBrowserCompatible": true, + "links": [ + { + "name": "website", + "position": 1, + "url": "https://spok.hydrogen.argent47.net" + } + ], + "logoUrl": "https://static.hydrogen.argent47.net/dapp/logos/e9998c6c-6d99-446b-9f48-fd0cec819298.png", + "name": "Spok", + "supportedApps": [] + }, + { + "argentVerified": true, + "categories": [], + "contracts": [], + "dappId": "90be09e5-e782-4b70-9bde-03e212389c4c", + "dappUrl": "https://provisions.starknet.io/", + "description": "Allocating STRK Tokens to the Community", + "executeFromOutsideAllowed": false, + "inAppBrowserCompatible": true, + "links": [ + { + "name": "website", + "position": 1, + "url": "https://provisions.starknet.io/" + } + ], + "logoUrl": "https://static.hydrogen.argent47.net/dapp/logos/90be09e5-e782-4b70-9bde-03e212389c4c.png", + "name": "Starknet Provisions", + "supportedApps": [] + }, + { + "argentVerified": false, + "categories": ["DeFi", "Infrastructure"], + "contracts": [ + { + "address": "0x06d8cd321dcbbf54512eab67c8a6849faf920077a3996f40bb4761adc4f021d2", + "chain": "starknet" + }, + { + "address": "0x07e36202ace0ab52bf438bd8a8b64b3731c48d09f0d8879f5b006384c2f35032", + "chain": "starknet" + } + ], + "dappId": "d030723c-e9e9-435b-bb72-e1df16ea9d2b", + "dappUrl": "https://app.avnu.fi/", + "description": "Best execution on Starknet. No fluff.", + "executeFromOutsideAllowed": false, + "inAppBrowserCompatible": true, + "links": [ + { + "name": "website", + "position": 1, + "url": "https://avnu.fi" + }, + { + "name": "twitter", + "position": 2, + "url": "https://twitter.com/avnu_fi" + }, + { + "name": "discord", + "position": 3, + "url": "https://discord.gg/invite/avnu-fi" + } + ], + "logoUrl": "https://static.hydrogen.argent47.net/dapp/logos/d030723c-e9e9-435b-bb72-e1df16ea9d2b.jpg", + "name": "AVNU", + "sessionConfig": {}, + "supportedApps": [] + }, + { + "argentVerified": true, + "contracts": [], + "dappId": "49007844-7a78-4185-be2f-b06bf6fc26a5", + "dappUrl": "https://stake.lido.fi/", + "description": "Empowering and securing Ethereum since 2020", + "executeFromOutsideAllowed": false, + "inAppBrowserCompatible": false, + "links": [ + { + "name": "website", + "position": 1, + "url": "https://lido.fi/" + } + ], + "logoUrl": "https://dv3jj1unlp2jl.cloudfront.net/128/color/protocols/eth2.png", + "name": "Lido" + }, + { + "argentVerified": false, + "brandColor": "#B51012", + "categories": ["DeFi"], + "contracts": [], + "dappId": "f839e37a-b68d-422d-ad79-8c2c1d16b49e", + "description": "Powerful Stableswap and AMM Aggregator on Starknet", + "executeFromOutsideAllowed": false, + "inAppBrowserCompatible": false, + "links": [ + { + "name": "website", + "position": 1, + "url": "https://sithswap.com/" + }, + { + "name": "twitter", + "position": 2, + "url": "https://twitter.com/SithSwap" + }, + { + "name": "discord", + "position": 3, + "url": "https://discord.gg/gAD2wuzeNf" + } + ], + "logoUrl": "https://www.dappland.com/dapps/sithswap/HCaScPSz_400x400.jpg", + "name": "SithSwap" + }, + { + "argentVerified": false, + "brandColor": "#FF4140", + "categories": ["DeFi"], + "contracts": [], + "dappId": "e72933f0-0485-4369-87d7-356e08e2dbb5", + "description": "Liquidity at your fingertips", + "executeFromOutsideAllowed": false, + "inAppBrowserCompatible": false, + "links": [ + { + "name": "website", + "position": 1, + "url": "https://nostra.finance/" + }, + { + "name": "twitter", + "position": 2, + "url": "https://twitter.com/nostrafinance" + }, + { + "name": "discord", + "position": 3, + "url": "https://discord.gg/Eqp53YafYP" + } + ], + "logoUrl": "https://www.dappland.com/dapps/nostra/dapp-icon-nostra.png", + "name": "Nostra" + }, + { + "argentVerified": true, + "brandColor": "#6B46C1", + "categories": ["DeFi", "Infrastructure"], + "contracts": [ + { + "address": "0x031e8a7ab6a6a556548ac85cbb8b5f56e8905696e9f13e9a858142b8ee0cc221", + "chain": "starknet" + }, + { + "address": "0x073fa8432bf59f8ed535f29acfd89a7020758bda7be509e00dfed8a9fde12ddc", + "chain": "starknet" + }, + { + "address": "0x01090e3cfd9990c396f246cd1d5c7fb091905cba9f99739653db1f2960a3311f", + "chain": "starknet" + }, + { + "address": "0x0384211022228b84eda3a07336b68e83618ee658ad92ff7b176a9e4958b1ae51", + "chain": "starknet" + }, + { + "address": "0x04c95177eb2aee798d901c34d825715136d5dd33c5cbeff1930e52f8b74ce3c3", + "chain": "starknet" + }, + { + "address": "0x0205b51a3ca54e718dac5f8424c7e67b6d169af5255316ff65be388f0a7e2eaf", + "chain": "starknet" + }, + { + "address": "0x037d4ba7ea2cd87993f2c0e38080ed442c740c536ae2861a61ac6ff8228964aa", + "chain": "starknet" + }, + { + "address": "0x050d4da9f66589eadaa1d5e31cf73b08ac1a67c8b4dcd88e6fd4fe501c628af2", + "chain": "starknet" + } + ], + "dappId": "b513a7c1-eb8a-4201-876c-becd8d445e15", + "dappUrl": "https://app.ekubo.org/", + "description": "Next-generation AMM built for Starknet", + "executeFromOutsideAllowed": false, + "inAppBrowserCompatible": true, + "links": [ + { + "name": "website", + "position": 1, + "url": "https://ekubo.org" + }, + { + "name": "twitter", + "position": 2, + "url": "https://twitter.com/EkuboProtocol" + }, + { + "name": "discord", + "position": 3, + "url": "https://discord.gg/RFbSXxtqUG" + } + ], + "logoUrl": "https://www.dappland.com/dapps/ekubo/ekubo-logo.png", + "name": "Ekubo", + "supportedApps": [] + }, + { + "argentVerified": false, + "brandColor": "#188EB3", + "categories": [], + "contracts": [ + { + "address": "0x018a439bcbb1b3535a6145c1dc9bc6366267d923f60a84bd0c7618f33c81d334", + "chain": "starknet" + }, + { + "address": "0x0436924c4ed166d3c283d516adc424976cfccba108e3a0e3f3fc1ef319e23aa7", + "chain": "starknet" + }, + { + "address": "0x052d99ab5835a0c295567dcdd40e9e281378d26d02ecca7b31e33dea99bc02ea", + "chain": "starknet" + } + ], + "dappId": "9bd46d82-3c05-4faa-ae17-72eef3d146fa", + "dappUrl": "https://www.myswap.xyz/", + "description": "the first AMM released on #Starknet", + "executeFromOutsideAllowed": false, + "inAppBrowserCompatible": false, + "links": [], + "logoUrl": "https://static.hydrogen.argent47.net/dapp/logos/9bd46d82-3c05-4faa-ae17-72eef3d146fa.png", + "name": "mySwap", + "supportedApps": [] + }, + { + "argentVerified": true, + "brandColor": "#2AAAFD", + "categories": [], + "contracts": [], + "dappId": "a562b4f6-446e-4f80-9b3a-4d617806a758", + "dappUrl": "https://dapp-ruby.vercel.app/", + "description": "Argent Test dapp including latest StarknetKit version", + "executeFromOutsideAllowed": false, + "inAppBrowserCompatible": true, + "links": [], + "logoUrl": "https://static.hydrogen.argent47.net/dapp/logos/a562b4f6-446e-4f80-9b3a-4d617806a758.png", + "name": "StarknetKit Test Dapp", + "supportedApps": [] + }, + { + "argentVerified": true, + "brandColor": "#000000", + "categories": ["DeFi"], + "contracts": [ + { + "address": "0x0570ad94639051fc46261a4b8570d399a88bd8835f5714372095ca01bb6f4c12", + "chain": "starknet" + } + ], + "dappId": "02d43d9d-b82e-44fb-aaa1-69753adc2f14", + "dappUrl": "https://vesu-git-sepolia-vesu.vercel.app", + "description": "Vesu is a public lending protocol allowing everyone to earn, borrow and build freely and securely.", + "executeFromOutsideAllowed": false, + "inAppBrowserCompatible": false, + "links": [], + "logoUrl": "https://www.dappland.com/dapps/vesu/vesu-logo.png", + "name": "Vesu" + }, + { + "argentVerified": false, + "categories": ["NFTs", "Games", "DAOs"], + "contracts": [ + { + "address": "0x075a180e18e56da1b1cae181c92a288f586f5fe22c18df21cf97886f1e4b316c", + "chain": "starknet" + } + ], + "dappId": "b7458fe1-a290-44c0-898f-4157961f1ad9", + "dappUrl": "https://game-prerelease.influenceth.io", + "description": "A grand strategy space MMO", + "executeFromOutsideAllowed": true, + "inAppBrowserCompatible": false, + "links": [ + { + "name": "website", + "position": 1, + "url": "https://www.influenceth.io/" + }, + { + "name": "twitter", + "position": 2, + "url": "https://twitter.com/influenceth" + }, + { + "name": "discord", + "position": 3, + "url": "https://discord.gg/UHMqbznhJS" + } + ], + "logoUrl": "https://www.dappland.com/dapps/influence/WEG3UrcU_400x400.jpg", + "name": "Influence", + "sessionConfig": { + "allowList": [ + { + "contractAddress": "0x0517567ac7026ce129c950e6e113e437aa3c83716cd61481c6bb8c5057e6923e", + "methods": ["run_system"] + }, + { + "contractAddress": "0x0030058f19ed447208015f6430f0102e8ab82d6c291566d7e73fe8e613c3d2ed", + "methods": ["transfer_with_confirmation", "transfer"] + }, + { + "contractAddress": "0x04541d894b5c0476d620e62c3a9be4719ba2dc652df84148be382a89619864e2", + "methods": ["withdraw", "deposit"] + } + ], + "maxExpiryDays": 7 + }, + "supportedApps": [] + }, + { + "argentVerified": false, + "categories": [], + "contracts": [], + "dappId": "b6bfb979-05c3-4963-873b-99775663b0a6", + "dappUrl": "https://www.kulipa.xyz", + "description": "Argent Card Partner", + "executeFromOutsideAllowed": false, + "inAppBrowserCompatible": false, + "links": [], + "logoUrl": "https://static.hydrogen.argent47.net/dapp/logos/b6bfb979-05c3-4963-873b-99775663b0a6.jpg", + "name": "Kulipa", + "sessionConfig": { + "allowList": [ + { + "contractAddress": "0x053b40a647cedfca6ca84f542a0fe36736031905a9639a7f19a3c1e66bfd5080", + "methods": ["transfer"] + } + ], + "maxExpiryDays": 7 + }, + "supportedApps": [] + }, + { + "argentVerified": false, + "categories": [], + "contracts": [], + "dappId": "d27415ac-30e4-4865-a316-bf04ce4510ac", + "dappUrl": "http://www.google.com", + "description": "Used for testing purposes", + "executeFromOutsideAllowed": false, + "inAppBrowserCompatible": false, + "links": [ + { + "name": "website", + "position": 1, + "url": "https://www.google.com" + } + ], + "logoUrl": "https://static.hydrogen.argent47.net/dapp/logos/b13b2bf5-84a2-4bd5-963d-993f37571204.png", + "name": "Test Increment Counter", + "sessionConfig": { + "allowList": [ + { + "contractAddress": "0x036133c88c1954413150db74c26243e2af77170a4032934b275708d84ec5452f", + "methods": ["increment"] + } + ], + "maxExpiryDays": 31 + }, + "supportedApps": [] + } + ], + "repository:nftsContracts_v2": [], + "repository:nfts_v2": [], + "service:activityCache:cache": { + "0x01060ea20f2778571e616a803bb54f8546af4a067d9fcd44c9e9543b418d1001::sepolia-alpha::local_secret::1": { + "activities": [ + { + "actions": [ + { + "defaultProperties": [ + { + "label": "default_contract", + "token": { + "address": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + "decimals": 18, + "iconUrl": "https://dv3jj1unlp2jl.cloudfront.net/128/color/eth.png", + "name": "Ether", + "symbol": "ETH", + "type": "ERC20", + "unknown": false + }, + "type": "token_address" + }, + { + "calldata": [ + "977312601197437022110323227435224271452751151768572207166894820017262369728", + "10000000000000", + "0" + ], + "entrypoint": "transfer", + "label": "default_call", + "type": "calldata" + } + ], + "name": "ERC20_transfer", + "properties": [ + { + "amount": "10000000000000", + "editable": false, + "label": "ERC20_transfer_amount", + "token": { + "address": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + "decimals": 18, + "iconUrl": "https://dv3jj1unlp2jl.cloudfront.net/128/color/eth.png", + "name": "Ether", + "symbol": "ETH", + "type": "ERC20", + "unknown": false + }, + "type": "amount", + "usd": "0.03" + }, + { + "address": "0x022923ab1d747a5f534a27c9ff7fd9ba3d7cd2d7142c46f7821dd07b9d0d1bc0", + "label": "ERC20_transfer_recipient", + "type": "address", + "verified": false + } + ] + } + ], + "lastModified": 1725359181031, + "meta": { + "icon": "SendIcon", + "subtitle": "To: 0x0229...1BC0", + "title": "Send" + }, + "status": "success", + "submitted": 1725359181000, + "transaction": { + "hash": "0x0758e1cf3753b1fa5fdaf82c7355c5c651a6153fb131864f7be0d35ee901d1d8" + }, + "transferSummary": [ + { + "asset": { + "amount": "10000000000000", + "fiatAmount": { + "currency": "USD", + "currencyAmount": "0.03" + }, + "tokenAddress": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + "type": "token" + }, + "sent": true + } + ], + "type": "native" + }, + { + "actions": [ + { + "defaultProperties": [ + { + "address": "0x01060ea20f2778571e616a803bb54f8546af4a067d9fcd44c9e9543b418d1001", + "label": "default_contract", + "type": "address", + "verified": false + }, + { + "calldata": [ + "0x2e3bbad3710ff3dc235c0d9b165346b863e58a4459a81ee2bfdc2299e3ea2e3", + "0x674870dedc85ff355200bab2d00c145f42d7d67d790b0f479dbdd2a42d55fbf" + ], + "entrypoint": "replace_signer", + "label": "default_call", + "type": "calldata" + } + ], + "name": "account_multisig_replace_signer", + "properties": [ + { + "address": "0x02e3bbad3710ff3dc235c0d9b165346b863e58a4459a81ee2bfdc2299e3ea2e3", + "label": "account_multisig_replace_signer_removed_signer", + "type": "address" + }, + { + "address": "0x0674870dedc85ff355200bab2d00c145f42d7d67d790b0f479dbdd2a42d55fbf", + "label": "account_multisig_replace_signer_added_signer", + "type": "address" + } + ] + } + ], + "compositeId": "90e74e3200f829992278b83dcb2e14335fafaf008c0a59b6ccb96a5cf25edb11", + "details": { + "action": "multisigConfigurationUpdated", + "type": "security" + }, + "fees": [ + { + "actualFee": { + "amount": "235623746046612", + "fiatAmount": { + "currency": "USD", + "currencyAmount": 0.5901 + }, + "tokenAddress": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + "type": "token" + }, + "to": "0x1176a1bd84444c89232ec27754698e5d2e7e1a7f1539f12027f28b23ec9f3d8", + "type": "gas" + } + ], + "group": "security", + "id": "33d5644e-95ce-4b4c-84f0-3ee7869ea922", + "lastModified": 1725359192692, + "multisigDetails": { + "signers": [ + "0x04f7808289a0cec299263ffa85d2c929b823d8849db6994173865f7208a907cd", + "0x02e3bbad3710ff3dc235c0d9b165346b863e58a4459a81ee2bfdc2299e3ea2e3" + ] + }, + "network": "starknet", + "networkDetails": { + "chainId": "SEPOLIA", + "ethereumNetwork": "sepolia" + }, + "relatedAddresses": [], + "source": "transaction-monitor", + "status": "success", + "submitted": 1725359167013, + "tags": [], + "transaction": { + "hash": "0x064c06998e11dd59098f580a588f75ec0387ea7e971511f6721cfd46b526b27b", + "network": "starknet", + "status": "pending", + "transactionIndex": 13 + }, + "transferSummary": [], + "transfers": [ + { + "asset": { + "amount": "235623746046612", + "fiatAmount": { + "currency": "USD", + "currencyAmount": 0.5901 + }, + "tokenAddress": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + "type": "token" + }, + "counterparty": "0x1176a1bd84444c89232ec27754698e5d2e7e1a7f1539f12027f28b23ec9f3d8", + "counterpartyNetwork": "starknet", + "leg": "debit", + "type": "gasFee" + } + ], + "triggeredBalanceUpdate": false, + "txSender": "0x01060ea20f2778571e616a803bb54f8546af4a067d9fcd44c9e9543b418d1001", + "type": "security", + "wallet": "0x01060ea20f2778571e616a803bb54f8546af4a067d9fcd44c9e9543b418d1001" + }, + { + "actions": [ + { + "defaultProperties": [ + { + "label": "default_contract", + "token": { + "address": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + "decimals": 18, + "iconUrl": "https://dv3jj1unlp2jl.cloudfront.net/128/color/eth.png", + "name": "Ether", + "symbol": "ETH", + "type": "ERC20", + "unknown": false + }, + "type": "token_address" + }, + { + "calldata": [ + "0x22923ab1d747a5f534a27c9ff7fd9ba3d7cd2d7142c46f7821dd07b9d0d1bc0", + "0x12309ce54000", + "0x0" + ], + "entrypoint": "transfer", + "label": "default_call", + "type": "calldata" + } + ], + "name": "ERC20_transfer", + "properties": [ + { + "amount": "20000000000000", + "editable": false, + "label": "ERC20_transfer_amount", + "token": { + "address": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + "decimals": 18, + "iconUrl": "https://dv3jj1unlp2jl.cloudfront.net/128/color/eth.png", + "name": "Ether", + "symbol": "ETH", + "type": "ERC20", + "unknown": false + }, + "type": "amount", + "usd": "0.05" + }, + { + "address": "0x022923ab1d747a5f534a27c9ff7fd9ba3d7cd2d7142c46f7821dd07b9d0d1bc0", + "label": "ERC20_transfer_recipient", + "type": "address", + "verified": false + } + ] + } + ], + "compositeId": "ca93351a8db6724912212765707ba611ad05823568cf74ae49ddc157937f0aba", + "details": { + "asset": { + "amount": "20000000000000", + "fiatAmount": { + "currency": "USD", + "currencyAmount": 0.0501 + }, + "tokenAddress": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + "type": "token" + }, + "counterparty": "0x22923ab1d747a5f534a27c9ff7fd9ba3d7cd2d7142c46f7821dd07b9d0d1bc0", + "counterpartyNetwork": "starknet", + "leg": "debit", + "type": "payment" + }, + "fees": [ + { + "actualFee": { + "amount": "131416451784618", + "fiatAmount": { + "currency": "USD", + "currencyAmount": 0.3291 + }, + "tokenAddress": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + "type": "token" + }, + "to": "0x1176a1bd84444c89232ec27754698e5d2e7e1a7f1539f12027f28b23ec9f3d8", + "type": "gas" + } + ], + "group": "finance", + "id": "0c85e5c0-4ce6-4e9d-8901-bfffb43f8606", + "lastModified": 1725359181266, + "multisigDetails": { + "signers": [ + "0x04f7808289a0cec299263ffa85d2c929b823d8849db6994173865f7208a907cd", + "0x02e3bbad3710ff3dc235c0d9b165346b863e58a4459a81ee2bfdc2299e3ea2e3" + ] + }, + "network": "starknet", + "networkDetails": { + "chainId": "SEPOLIA", + "ethereumNetwork": "sepolia" + }, + "relatedAddresses": [ + { + "address": "0x01060ea20f2778571e616a803bb54f8546af4a067d9fcd44c9e9543b418d1001", + "network": "starknet", + "type": "wallet" + }, + { + "address": "0x022923ab1d747a5f534a27c9ff7fd9ba3d7cd2d7142c46f7821dd07b9d0d1bc0", + "network": "starknet", + "type": "wallet" + }, + { + "address": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + "network": "starknet", + "type": "token" + } + ], + "source": "transaction-monitor", + "status": "success", + "submitted": 1725359167008, + "tags": [], + "title": "Send", + "transaction": { + "hash": "0x04aecd6afbc6c8676f2232486d63c74d63bb92210f9b804650fbdcc54bcacfde", + "network": "starknet", + "status": "pending", + "transactionIndex": 8 + }, + "transferSummary": [ + { + "asset": { + "amount": "20000000000000", + "fiatAmount": { + "currency": "USD", + "currencyAmount": 0.0501 + }, + "tokenAddress": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + "type": "token" + }, + "sent": true + } + ], + "transfers": [ + { + "asset": { + "amount": "20000000000000", + "fiatAmount": { + "currency": "USD", + "currencyAmount": 0.0501 + }, + "tokenAddress": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + "type": "token" + }, + "counterparty": "0x22923ab1d747a5f534a27c9ff7fd9ba3d7cd2d7142c46f7821dd07b9d0d1bc0", + "counterpartyNetwork": "starknet", + "leg": "debit", + "type": "payment" + }, + { + "asset": { + "amount": "131416451784618", + "fiatAmount": { + "currency": "USD", + "currencyAmount": 0.3291 + }, + "tokenAddress": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + "type": "token" + }, + "counterparty": "0x1176a1bd84444c89232ec27754698e5d2e7e1a7f1539f12027f28b23ec9f3d8", + "counterpartyNetwork": "starknet", + "leg": "debit", + "type": "gasFee" + } + ], + "triggeredBalanceUpdate": false, + "txSender": "0x01060ea20f2778571e616a803bb54f8546af4a067d9fcd44c9e9543b418d1001", + "type": "payment", + "wallet": "0x01060ea20f2778571e616a803bb54f8546af4a067d9fcd44c9e9543b418d1001" + }, + { + "compositeId": "15ae7bcd918e26b4c450e73960363d5ede5fd55e7f98d90a91944dcd0d512097", + "details": { + "action": "multisigConfigurationUpdated", + "type": "security" + }, + "fees": [ + { + "actualFee": { + "amount": "271196639467336", + "fiatAmount": { + "currency": "USD", + "currencyAmount": 0.6792 + }, + "tokenAddress": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + "type": "token" + }, + "to": "0x1176a1bd84444c89232ec27754698e5d2e7e1a7f1539f12027f28b23ec9f3d8", + "type": "gas" + } + ], + "group": "security", + "id": "44e7ba41-07b0-4459-b666-73ae5bcfab7f", + "lastModified": 1725358988350, + "network": "starknet", + "networkDetails": { + "chainId": "SEPOLIA", + "ethereumNetwork": "sepolia" + }, + "relatedAddresses": [], + "source": "transaction-monitor", + "status": "success", + "submitted": 1725358955008, + "tags": [], + "transaction": { + "blockNumber": 157030, + "hash": "0x04560a055fcedc24a374e553a7e9946a2231ccb997a9130758c0a6235a9aa4d3", + "network": "starknet", + "status": "success", + "transactionIndex": 8 + }, + "transferSummary": [], + "transfers": [ + { + "asset": { + "amount": "271196639467336", + "fiatAmount": { + "currency": "USD", + "currencyAmount": 0.6792 + }, + "tokenAddress": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + "type": "token" + }, + "counterparty": "0x1176a1bd84444c89232ec27754698e5d2e7e1a7f1539f12027f28b23ec9f3d8", + "counterpartyNetwork": "starknet", + "leg": "debit", + "type": "gasFee" + } + ], + "triggeredBalanceUpdate": false, + "txSender": "0x01060ea20f2778571e616a803bb54f8546af4a067d9fcd44c9e9543b418d1001", + "type": "security", + "wallet": "0x01060ea20f2778571e616a803bb54f8546af4a067d9fcd44c9e9543b418d1001" + }, + { + "compositeId": "440ee683e13f38509c3d9ef111f04a344096a5cecf4874c72816c3ecedcdc5a5", + "details": { + "asset": { + "amount": "25000000000000000", + "fiatAmount": { + "currency": "USD", + "currencyAmount": 62.6101 + }, + "tokenAddress": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + "type": "token" + }, + "counterparty": "0x324b04e5a4270605007334372ce455c47581e51cdd091559df2739d1c4e2677", + "counterpartyNetwork": "starknet", + "leg": "credit", + "type": "payment" + }, + "fees": [], + "group": "finance", + "id": "1c4b9a91-840a-4152-896d-46733316d179", + "lastModified": 1725358988407, + "network": "starknet", + "networkDetails": { + "chainId": "SEPOLIA", + "ethereumNetwork": "sepolia" + }, + "relatedAddresses": [ + { + "address": "0x01060ea20f2778571e616a803bb54f8546af4a067d9fcd44c9e9543b418d1001", + "network": "starknet", + "type": "wallet" + }, + { + "address": "0x0324b04e5a4270605007334372ce455c47581e51cdd091559df2739d1c4e2677", + "network": "starknet", + "type": "wallet" + }, + { + "address": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + "network": "starknet", + "type": "token" + } + ], + "source": "transaction-monitor", + "status": "success", + "submitted": 1725358955001, + "tags": [], + "title": "Receive", + "transaction": { + "blockNumber": 157030, + "hash": "0x07180fa03db355ce6b231ce98548bae6a482ce643816c4533f4d79aca930e0bb", + "network": "starknet", + "status": "success", + "transactionIndex": 1 + }, + "transferSummary": [ + { + "asset": { + "amount": "25000000000000000", + "fiatAmount": { + "currency": "USD", + "currencyAmount": 62.6101 + }, + "tokenAddress": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + "type": "token" + }, + "sent": false + } + ], + "transfers": [ + { + "asset": { + "amount": "25000000000000000", + "fiatAmount": { + "currency": "USD", + "currencyAmount": 62.6101 + }, + "tokenAddress": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + "type": "token" + }, + "counterparty": "0x324b04e5a4270605007334372ce455c47581e51cdd091559df2739d1c4e2677", + "counterpartyNetwork": "starknet", + "leg": "credit", + "type": "payment" + } + ], + "triggeredBalanceUpdate": false, + "txSender": "0x0324b04e5a4270605007334372ce455c47581e51cdd091559df2739d1c4e2677", + "type": "payment", + "wallet": "0x01060ea20f2778571e616a803bb54f8546af4a067d9fcd44c9e9543b418d1001" + } + ], + "updatedAt": 1725359180894 + }, + "0x051cda0876cb58b05d9a6d9da52b00b8d04733028def56668867e3bffd9fac47::sepolia-alpha::ledger::43": { + "activities": [ + { + "actions": [ + { + "defaultProperties": [ + { + "label": "default_contract", + "token": { + "address": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + "decimals": 18, + "iconUrl": "https://dv3jj1unlp2jl.cloudfront.net/128/color/eth.png", + "name": "Ether", + "symbol": "ETH", + "type": "ERC20", + "unknown": false + }, + "type": "token_address" + }, + { + "calldata": [ + "977312601197437022110323227435224271452751151768572207166894820017262369728", + "200000000000000", + "0" + ], + "entrypoint": "transfer", + "label": "default_call", + "type": "calldata" + } + ], + "name": "ERC20_transfer", + "properties": [ + { + "amount": "200000000000000", + "editable": false, + "label": "ERC20_transfer_amount", + "token": { + "address": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + "decimals": 18, + "iconUrl": "https://dv3jj1unlp2jl.cloudfront.net/128/color/eth.png", + "name": "Ether", + "symbol": "ETH", + "type": "ERC20", + "unknown": false + }, + "type": "amount", + "usd": "0.50" + }, + { + "address": "0x022923ab1d747a5f534a27c9ff7fd9ba3d7cd2d7142c46f7821dd07b9d0d1bc0", + "label": "ERC20_transfer_recipient", + "type": "address", + "verified": false + } + ] + } + ], + "fees": [ + { + "actualFee": { + "amount": "151984448452486", + "fiatAmount": { + "currency": "USD", + "currencyAmount": 0.3804 + }, + "tokenAddress": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + "type": "token" + }, + "to": "0x1176a1bd84444c89232ec27754698e5d2e7e1a7f1539f12027f28b23ec9f3d8", + "type": "gas" + } + ], + "lastModified": 1725357553661, + "meta": { + "icon": "SendIcon", + "subtitle": "To: 0x0229...1BC0", + "title": "Send" + }, + "multisigDetails": { + "signers": [ + "0x01fbb2e03e41d28b0d572d48c2ef4bb1d2686f37f702e26f179c5101e0587048", + "0x040b7b6e0ca831417a7f6f2938850e8d91d95373eaafac012c95614d68c98e84" + ] + }, + "status": "success", + "submitted": 1725357553000, + "transaction": { + "hash": "0x049924a6583e6524c95e0b38ab3ef3757899b62ef26c8c70dfffb847e2ddaa37" + }, + "transferSummary": [ + { + "asset": { + "amount": "200000000000000", + "fiatAmount": { + "currency": "USD", + "currencyAmount": 0.5006 + }, + "tokenAddress": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + "type": "token" + }, + "sent": true + } + ], + "type": "native" + }, + { + "actions": [ + { + "defaultProperties": [ + { + "address": "0x051cda0876cb58b05d9a6d9da52b00b8d04733028def56668867e3bffd9fac47", + "label": "default_contract", + "type": "address", + "verified": false + }, + { + "calldata": [ + "0x40b7b6e0ca831417a7f6f2938850e8d91d95373eaafac012c95614d68c98e84", + "0x826a255157914857abb3619d8930a8f43c88923dface0583c81c7289f52b31" + ], + "entrypoint": "replace_signer", + "label": "default_call", + "type": "calldata" + } + ], + "name": "account_multisig_replace_signer", + "properties": [ + { + "address": "0x040b7b6e0ca831417a7f6f2938850e8d91d95373eaafac012c95614d68c98e84", + "label": "account_multisig_replace_signer_removed_signer", + "type": "address" + }, + { + "address": "0x00826a255157914857abb3619d8930a8f43c88923dface0583c81c7289f52b31", + "label": "account_multisig_replace_signer_added_signer", + "type": "address" + } + ] + } + ], + "compositeId": "3c209a9a11d9875fef2b11fc6e88cfaff0f32c54c24890507a3e437f823c6528", + "details": { + "action": "multisigConfigurationUpdated", + "type": "security" + }, + "fees": [ + { + "actualFee": { + "amount": "272501232523724", + "fiatAmount": { + "currency": "USD", + "currencyAmount": 0.6821 + }, + "tokenAddress": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + "type": "token" + }, + "to": "0x1176a1bd84444c89232ec27754698e5d2e7e1a7f1539f12027f28b23ec9f3d8", + "type": "gas" + } + ], + "group": "security", + "id": "87a6a459-f709-4be0-88f4-ffeb29bdc859", + "lastModified": 1725357530434, + "multisigDetails": { + "signers": [ + "0x040b7b6e0ca831417a7f6f2938850e8d91d95373eaafac012c95614d68c98e84", + "0x01fbb2e03e41d28b0d572d48c2ef4bb1d2686f37f702e26f179c5101e0587048" + ] + }, + "network": "starknet", + "networkDetails": { + "chainId": "SEPOLIA", + "ethereumNetwork": "sepolia" + }, + "relatedAddresses": [], + "source": "transaction-monitor", + "status": "success", + "submitted": 1725357497008, + "tags": [], + "transaction": { + "blockNumber": 156992, + "hash": "0x05b89ba4120fab620dfbc33cfef3a0f1235d55e8b5afe3cafd8fb28aa1eca8fe", + "network": "starknet", + "status": "success", + "transactionIndex": 8 + }, + "transferSummary": [], + "transfers": [ + { + "asset": { + "amount": "272501232523724", + "fiatAmount": { + "currency": "USD", + "currencyAmount": 0.6821 + }, + "tokenAddress": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + "type": "token" + }, + "counterparty": "0x1176a1bd84444c89232ec27754698e5d2e7e1a7f1539f12027f28b23ec9f3d8", + "counterpartyNetwork": "starknet", + "leg": "debit", + "type": "gasFee" + } + ], + "triggeredBalanceUpdate": false, + "txSender": "0x051cda0876cb58b05d9a6d9da52b00b8d04733028def56668867e3bffd9fac47", + "type": "security", + "wallet": "0x051cda0876cb58b05d9a6d9da52b00b8d04733028def56668867e3bffd9fac47" + }, + { + "actions": [ + { + "defaultProperties": [ + { + "label": "default_contract", + "token": { + "address": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + "decimals": 18, + "iconUrl": "https://dv3jj1unlp2jl.cloudfront.net/128/color/eth.png", + "name": "Ether", + "symbol": "ETH", + "type": "ERC20", + "unknown": false + }, + "type": "token_address" + }, + { + "calldata": [ + "0x22923ab1d747a5f534a27c9ff7fd9ba3d7cd2d7142c46f7821dd07b9d0d1bc0", + "0x9184e72a000", + "0x0" + ], + "entrypoint": "transfer", + "label": "default_call", + "type": "calldata" + } + ], + "name": "ERC20_transfer", + "properties": [ + { + "amount": "10000000000000", + "editable": false, + "label": "ERC20_transfer_amount", + "token": { + "address": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + "decimals": 18, + "iconUrl": "https://dv3jj1unlp2jl.cloudfront.net/128/color/eth.png", + "name": "Ether", + "symbol": "ETH", + "type": "ERC20", + "unknown": false + }, + "type": "amount", + "usd": "0.03" + }, + { + "address": "0x022923ab1d747a5f534a27c9ff7fd9ba3d7cd2d7142c46f7821dd07b9d0d1bc0", + "label": "ERC20_transfer_recipient", + "type": "address", + "verified": false + } + ] + } + ], + "compositeId": "944a788e490eb038397116f04252686e92722c15dd4b0949f2afc89928e8f47d", + "details": { + "asset": { + "amount": "10000000000000", + "fiatAmount": { + "currency": "USD", + "currencyAmount": 0.025 + }, + "tokenAddress": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + "type": "token" + }, + "counterparty": "0x22923ab1d747a5f534a27c9ff7fd9ba3d7cd2d7142c46f7821dd07b9d0d1bc0", + "counterpartyNetwork": "starknet", + "leg": "debit", + "type": "payment" + }, + "fees": [ + { + "actualFee": { + "amount": "151984448452486", + "fiatAmount": { + "currency": "USD", + "currencyAmount": 0.3804 + }, + "tokenAddress": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + "type": "token" + }, + "to": "0x1176a1bd84444c89232ec27754698e5d2e7e1a7f1539f12027f28b23ec9f3d8", + "type": "gas" + } + ], + "group": "finance", + "id": "d07e96b5-41d5-4a23-88c9-228cc1de9b17", + "lastModified": 1725357530483, + "multisigDetails": { + "signers": [ + "0x01fbb2e03e41d28b0d572d48c2ef4bb1d2686f37f702e26f179c5101e0587048", + "0x040b7b6e0ca831417a7f6f2938850e8d91d95373eaafac012c95614d68c98e84" + ] + }, + "network": "starknet", + "networkDetails": { + "chainId": "SEPOLIA", + "ethereumNetwork": "sepolia" + }, + "relatedAddresses": [ + { + "address": "0x022923ab1d747a5f534a27c9ff7fd9ba3d7cd2d7142c46f7821dd07b9d0d1bc0", + "network": "starknet", + "type": "wallet" + }, + { + "address": "0x051cda0876cb58b05d9a6d9da52b00b8d04733028def56668867e3bffd9fac47", + "network": "starknet", + "type": "wallet" + }, + { + "address": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + "network": "starknet", + "type": "token" + } + ], + "source": "transaction-monitor", + "status": "success", + "submitted": 1725357497001, + "tags": [], + "title": "Send", + "transaction": { + "blockNumber": 156992, + "hash": "0x0535bbf8e2eef80c0544769250578b2d4ae834ba39945e04bc7928f14080fe24", + "network": "starknet", + "status": "success", + "transactionIndex": 1 + }, + "transferSummary": [ + { + "asset": { + "amount": "10000000000000", + "fiatAmount": { + "currency": "USD", + "currencyAmount": 0.025 + }, + "tokenAddress": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + "type": "token" + }, + "sent": true + } + ], + "transfers": [ + { + "asset": { + "amount": "10000000000000", + "fiatAmount": { + "currency": "USD", + "currencyAmount": 0.025 + }, + "tokenAddress": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + "type": "token" + }, + "counterparty": "0x22923ab1d747a5f534a27c9ff7fd9ba3d7cd2d7142c46f7821dd07b9d0d1bc0", + "counterpartyNetwork": "starknet", + "leg": "debit", + "type": "payment" + }, + { + "asset": { + "amount": "151984448452486", + "fiatAmount": { + "currency": "USD", + "currencyAmount": 0.3804 + }, + "tokenAddress": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + "type": "token" + }, + "counterparty": "0x1176a1bd84444c89232ec27754698e5d2e7e1a7f1539f12027f28b23ec9f3d8", + "counterpartyNetwork": "starknet", + "leg": "debit", + "type": "gasFee" + } + ], + "triggeredBalanceUpdate": false, + "txSender": "0x051cda0876cb58b05d9a6d9da52b00b8d04733028def56668867e3bffd9fac47", + "type": "payment", + "wallet": "0x051cda0876cb58b05d9a6d9da52b00b8d04733028def56668867e3bffd9fac47" + }, + { + "compositeId": "a3d8e120bd6b64add0286d58ba94cf88ace5e8b3e93a720d88deb3e4af7f9313", + "details": { + "action": "multisigConfigurationUpdated", + "type": "security" + }, + "fees": [ + { + "actualFee": { + "amount": "308672584770216", + "fiatAmount": { + "currency": "USD", + "currencyAmount": 0.7726 + }, + "tokenAddress": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + "type": "token" + }, + "to": "0x1176a1bd84444c89232ec27754698e5d2e7e1a7f1539f12027f28b23ec9f3d8", + "type": "gas" + } + ], + "group": "security", + "id": "3e3267d2-f6e7-4bc8-839f-6e4ce916435f", + "lastModified": 1725357392952, + "network": "starknet", + "networkDetails": { + "chainId": "SEPOLIA", + "ethereumNetwork": "sepolia" + }, + "relatedAddresses": [], + "source": "transaction-monitor", + "status": "success", + "submitted": 1725357358001, + "tags": [], + "transaction": { + "blockNumber": 156988, + "hash": "0x07a5b0a67676a83dfe4155af7ef5f373521c0d2c4b476a591b7229b3add1a592", + "network": "starknet", + "status": "success", + "transactionIndex": 1 + }, + "transferSummary": [], + "transfers": [ + { + "asset": { + "amount": "308672584770216", + "fiatAmount": { + "currency": "USD", + "currencyAmount": 0.7726 + }, + "tokenAddress": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + "type": "token" + }, + "counterparty": "0x1176a1bd84444c89232ec27754698e5d2e7e1a7f1539f12027f28b23ec9f3d8", + "counterpartyNetwork": "starknet", + "leg": "debit", + "type": "gasFee" + } + ], + "triggeredBalanceUpdate": false, + "txSender": "0x051cda0876cb58b05d9a6d9da52b00b8d04733028def56668867e3bffd9fac47", + "type": "security", + "wallet": "0x051cda0876cb58b05d9a6d9da52b00b8d04733028def56668867e3bffd9fac47" + }, + { + "compositeId": "da75bd7e60db11a57d33f2547e8ebb7db9d1da4efb1cdf97f4e7db5a0a365d81", + "details": { + "asset": { + "amount": "25000000000000000", + "fiatAmount": { + "currency": "USD", + "currencyAmount": 62.5761 + }, + "tokenAddress": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + "type": "token" + }, + "counterparty": "0x324b04e5a4270605007334372ce455c47581e51cdd091559df2739d1c4e2677", + "counterpartyNetwork": "starknet", + "leg": "credit", + "type": "payment" + }, + "fees": [], + "group": "finance", + "id": "d5f77738-fef0-4ba1-893e-09fb88959606", + "lastModified": 1725357358781, + "network": "starknet", + "networkDetails": { + "chainId": "SEPOLIA", + "ethereumNetwork": "sepolia" + }, + "relatedAddresses": [ + { + "address": "0x0324b04e5a4270605007334372ce455c47581e51cdd091559df2739d1c4e2677", + "network": "starknet", + "type": "wallet" + }, + { + "address": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + "network": "starknet", + "type": "token" + }, + { + "address": "0x051cda0876cb58b05d9a6d9da52b00b8d04733028def56668867e3bffd9fac47", + "network": "starknet", + "type": "wallet" + } + ], + "source": "transaction-monitor", + "status": "success", + "submitted": 1725357327002, + "tags": [], + "title": "Receive", + "transaction": { + "blockNumber": 156987, + "hash": "0x06d4650284cc43b2ee0a6bc62455676e86b574779bd07cbc50396743bf6abcfc", + "network": "starknet", + "status": "success", + "transactionIndex": 2 + }, + "transferSummary": [ + { + "asset": { + "amount": "25000000000000000", + "fiatAmount": { + "currency": "USD", + "currencyAmount": 62.5761 + }, + "tokenAddress": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + "type": "token" + }, + "sent": false + } + ], + "transfers": [ + { + "asset": { + "amount": "25000000000000000", + "fiatAmount": { + "currency": "USD", + "currencyAmount": 62.5761 + }, + "tokenAddress": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + "type": "token" + }, + "counterparty": "0x324b04e5a4270605007334372ce455c47581e51cdd091559df2739d1c4e2677", + "counterpartyNetwork": "starknet", + "leg": "credit", + "type": "payment" + } + ], + "triggeredBalanceUpdate": false, + "txSender": "0x0324b04e5a4270605007334372ce455c47581e51cdd091559df2739d1c4e2677", + "type": "payment", + "wallet": "0x051cda0876cb58b05d9a6d9da52b00b8d04733028def56668867e3bffd9fac47" + } + ] + }, + "0x051cda0876cb58b05d9a6d9da52b00b8d04733028def56668867e3bffd9fac47::sepolia-alpha::local_secret::0": { + "activities": [ + { + "actions": [ + { + "defaultProperties": [ + { + "address": "0x051cda0876cb58b05d9a6d9da52b00b8d04733028def56668867e3bffd9fac47", + "label": "default_contract", + "type": "address", + "verified": false + }, + { + "calldata": [ + "1829538593773354249444540255815518873368041500203918684000932786719437721220", + "230422709618295256573259470771118736654803513281277606618280875439387126577" + ], + "entrypoint": "replace_signer", + "label": "default_call", + "type": "calldata" + } + ], + "name": "account_multisig_replace_signer", + "properties": [ + { + "address": "0x040b7b6e0ca831417a7f6f2938850e8d91d95373eaafac012c95614d68c98e84", + "label": "account_multisig_replace_signer_removed_signer", + "type": "address" + }, + { + "address": "0x00826a255157914857abb3619d8930a8f43c88923dface0583c81c7289f52b31", + "label": "account_multisig_replace_signer_added_signer", + "type": "address" + } + ] + } + ], + "lastModified": 1725357533659, + "meta": { + "icon": "MultisigReplaceIcon", + "title": "Replace signer" + }, + "status": "success", + "submitted": 1725357533000, + "transaction": { + "hash": "0x05b89ba4120fab620dfbc33cfef3a0f1235d55e8b5afe3cafd8fb28aa1eca8fe" + }, + "transferSummary": [], + "type": "native" + }, + { + "actions": [ + { + "defaultProperties": [ + { + "label": "default_contract", + "token": { + "address": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + "decimals": 18, + "iconUrl": "https://dv3jj1unlp2jl.cloudfront.net/128/color/eth.png", + "name": "Ether", + "symbol": "ETH", + "type": "ERC20", + "unknown": false + }, + "type": "token_address" + }, + { + "calldata": [ + "977312601197437022110323227435224271452751151768572207166894820017262369728", + "10000000000000", + "0" + ], + "entrypoint": "transfer", + "label": "default_call", + "type": "calldata" + } + ], + "name": "ERC20_transfer", + "properties": [ + { + "amount": "10000000000000", + "editable": false, + "label": "ERC20_transfer_amount", + "token": { + "address": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + "decimals": 18, + "iconUrl": "https://dv3jj1unlp2jl.cloudfront.net/128/color/eth.png", + "name": "Ether", + "symbol": "ETH", + "type": "ERC20", + "unknown": false + }, + "type": "amount", + "usd": "0.03" + }, + { + "address": "0x022923ab1d747a5f534a27c9ff7fd9ba3d7cd2d7142c46f7821dd07b9d0d1bc0", + "label": "ERC20_transfer_recipient", + "type": "address", + "verified": false + } + ] + } + ], + "fees": [ + { + "actualFee": { + "amount": "151984448452486", + "fiatAmount": { + "currency": "USD", + "currencyAmount": 0.3804 + }, + "tokenAddress": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + "type": "token" + }, + "to": "0x1176a1bd84444c89232ec27754698e5d2e7e1a7f1539f12027f28b23ec9f3d8", + "type": "gas" + } + ], + "lastModified": 1725357513666, + "meta": { + "icon": "SendIcon", + "subtitle": "To: 0x0229...1BC0", + "title": "Send" + }, + "multisigDetails": { + "signers": [ + "0x01fbb2e03e41d28b0d572d48c2ef4bb1d2686f37f702e26f179c5101e0587048", + "0x040b7b6e0ca831417a7f6f2938850e8d91d95373eaafac012c95614d68c98e84" + ] + }, + "status": "success", + "submitted": 1725357513000, + "transaction": { + "hash": "0x0535bbf8e2eef80c0544769250578b2d4ae834ba39945e04bc7928f14080fe24" + }, + "transferSummary": [ + { + "asset": { + "amount": "10000000000000", + "fiatAmount": { + "currency": "USD", + "currencyAmount": 0.025 + }, + "tokenAddress": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + "type": "token" + }, + "sent": true + } + ], + "type": "native" + }, + { + "actions": [ + { + "defaultProperties": [ + { + "label": "default_contract", + "token": { + "address": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + "decimals": 18, + "iconUrl": "https://dv3jj1unlp2jl.cloudfront.net/128/color/eth.png", + "name": "Ether", + "symbol": "ETH", + "type": "ERC20", + "unknown": false + }, + "type": "token_address" + }, + { + "calldata": [ + "0x22923ab1d747a5f534a27c9ff7fd9ba3d7cd2d7142c46f7821dd07b9d0d1bc0", + "0xb5e620f48000", + "0x0" + ], + "entrypoint": "transfer", + "label": "default_call", + "type": "calldata" + } + ], + "name": "ERC20_transfer", + "properties": [ + { + "amount": "200000000000000", + "editable": false, + "label": "ERC20_transfer_amount", + "token": { + "address": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + "decimals": 18, + "iconUrl": "https://dv3jj1unlp2jl.cloudfront.net/128/color/eth.png", + "name": "Ether", + "symbol": "ETH", + "type": "ERC20", + "unknown": false + }, + "type": "amount", + "usd": "0.50" + }, + { + "address": "0x022923ab1d747a5f534a27c9ff7fd9ba3d7cd2d7142c46f7821dd07b9d0d1bc0", + "label": "ERC20_transfer_recipient", + "type": "address", + "verified": false + } + ] + } + ], + "compositeId": "43529e4ccdf445bbfb919b3a279df54fdd71fb45260bac23a62a30a851e6634a", + "details": { + "asset": { + "amount": "200000000000000", + "fiatAmount": { + "currency": "USD", + "currencyAmount": 0.5006 + }, + "tokenAddress": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + "type": "token" + }, + "counterparty": "0x22923ab1d747a5f534a27c9ff7fd9ba3d7cd2d7142c46f7821dd07b9d0d1bc0", + "counterpartyNetwork": "starknet", + "leg": "debit", + "type": "payment" + }, + "fees": [ + { + "actualFee": { + "amount": "151984448452486", + "fiatAmount": { + "currency": "USD", + "currencyAmount": 0.3804 + }, + "tokenAddress": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + "type": "token" + }, + "to": "0x1176a1bd84444c89232ec27754698e5d2e7e1a7f1539f12027f28b23ec9f3d8", + "type": "gas" + } + ], + "group": "finance", + "id": "86e19b4c-7800-4c3d-9545-11d85e46e443", + "lastModified": 1725357530387, + "multisigDetails": { + "signers": [ + "0x01fbb2e03e41d28b0d572d48c2ef4bb1d2686f37f702e26f179c5101e0587048", + "0x040b7b6e0ca831417a7f6f2938850e8d91d95373eaafac012c95614d68c98e84" + ] + }, + "network": "starknet", + "networkDetails": { + "chainId": "SEPOLIA", + "ethereumNetwork": "sepolia" + }, + "relatedAddresses": [ + { + "address": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + "network": "starknet", + "type": "token" + }, + { + "address": "0x051cda0876cb58b05d9a6d9da52b00b8d04733028def56668867e3bffd9fac47", + "network": "starknet", + "type": "wallet" + }, + { + "address": "0x022923ab1d747a5f534a27c9ff7fd9ba3d7cd2d7142c46f7821dd07b9d0d1bc0", + "network": "starknet", + "type": "wallet" + } + ], + "source": "transaction-monitor", + "status": "success", + "submitted": 1725357497005, + "tags": [], + "title": "Send", + "transaction": { + "blockNumber": 156992, + "hash": "0x049924a6583e6524c95e0b38ab3ef3757899b62ef26c8c70dfffb847e2ddaa37", + "network": "starknet", + "status": "success", + "transactionIndex": 5 + }, + "transferSummary": [ + { + "asset": { + "amount": "200000000000000", + "fiatAmount": { + "currency": "USD", + "currencyAmount": 0.5006 + }, + "tokenAddress": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + "type": "token" + }, + "sent": true + } + ], + "transfers": [ + { + "asset": { + "amount": "200000000000000", + "fiatAmount": { + "currency": "USD", + "currencyAmount": 0.5006 + }, + "tokenAddress": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + "type": "token" + }, + "counterparty": "0x22923ab1d747a5f534a27c9ff7fd9ba3d7cd2d7142c46f7821dd07b9d0d1bc0", + "counterpartyNetwork": "starknet", + "leg": "debit", + "type": "payment" + }, + { + "asset": { + "amount": "151984448452486", + "fiatAmount": { + "currency": "USD", + "currencyAmount": 0.3804 + }, + "tokenAddress": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + "type": "token" + }, + "counterparty": "0x1176a1bd84444c89232ec27754698e5d2e7e1a7f1539f12027f28b23ec9f3d8", + "counterpartyNetwork": "starknet", + "leg": "debit", + "type": "gasFee" + } + ], + "triggeredBalanceUpdate": false, + "txSender": "0x051cda0876cb58b05d9a6d9da52b00b8d04733028def56668867e3bffd9fac47", + "type": "payment", + "wallet": "0x051cda0876cb58b05d9a6d9da52b00b8d04733028def56668867e3bffd9fac47" + }, + { + "compositeId": "a3d8e120bd6b64add0286d58ba94cf88ace5e8b3e93a720d88deb3e4af7f9313", + "details": { + "action": "multisigConfigurationUpdated", + "type": "security" + }, + "fees": [ + { + "actualFee": { + "amount": "308672584770216", + "fiatAmount": { + "currency": "USD", + "currencyAmount": 0.7726 + }, + "tokenAddress": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + "type": "token" + }, + "to": "0x1176a1bd84444c89232ec27754698e5d2e7e1a7f1539f12027f28b23ec9f3d8", + "type": "gas" + } + ], + "group": "security", + "id": "3e3267d2-f6e7-4bc8-839f-6e4ce916435f", + "lastModified": 1725357392952, + "network": "starknet", + "networkDetails": { + "chainId": "SEPOLIA", + "ethereumNetwork": "sepolia" + }, + "relatedAddresses": [], + "source": "transaction-monitor", + "status": "success", + "submitted": 1725357358001, + "tags": [], + "transaction": { + "blockNumber": 156988, + "hash": "0x07a5b0a67676a83dfe4155af7ef5f373521c0d2c4b476a591b7229b3add1a592", + "network": "starknet", + "status": "success", + "transactionIndex": 1 + }, + "transferSummary": [], + "transfers": [ + { + "asset": { + "amount": "308672584770216", + "fiatAmount": { + "currency": "USD", + "currencyAmount": 0.7726 + }, + "tokenAddress": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + "type": "token" + }, + "counterparty": "0x1176a1bd84444c89232ec27754698e5d2e7e1a7f1539f12027f28b23ec9f3d8", + "counterpartyNetwork": "starknet", + "leg": "debit", + "type": "gasFee" + } + ], + "triggeredBalanceUpdate": false, + "txSender": "0x051cda0876cb58b05d9a6d9da52b00b8d04733028def56668867e3bffd9fac47", + "type": "security", + "wallet": "0x051cda0876cb58b05d9a6d9da52b00b8d04733028def56668867e3bffd9fac47" + }, + { + "compositeId": "da75bd7e60db11a57d33f2547e8ebb7db9d1da4efb1cdf97f4e7db5a0a365d81", + "details": { + "asset": { + "amount": "25000000000000000", + "fiatAmount": { + "currency": "USD", + "currencyAmount": 62.5761 + }, + "tokenAddress": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + "type": "token" + }, + "counterparty": "0x324b04e5a4270605007334372ce455c47581e51cdd091559df2739d1c4e2677", + "counterpartyNetwork": "starknet", + "leg": "credit", + "type": "payment" + }, + "fees": [], + "group": "finance", + "id": "d5f77738-fef0-4ba1-893e-09fb88959606", + "lastModified": 1725357358781, + "network": "starknet", + "networkDetails": { + "chainId": "SEPOLIA", + "ethereumNetwork": "sepolia" + }, + "relatedAddresses": [ + { + "address": "0x0324b04e5a4270605007334372ce455c47581e51cdd091559df2739d1c4e2677", + "network": "starknet", + "type": "wallet" + }, + { + "address": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + "network": "starknet", + "type": "token" + }, + { + "address": "0x051cda0876cb58b05d9a6d9da52b00b8d04733028def56668867e3bffd9fac47", + "network": "starknet", + "type": "wallet" + } + ], + "source": "transaction-monitor", + "status": "success", + "submitted": 1725357327002, + "tags": [], + "title": "Receive", + "transaction": { + "blockNumber": 156987, + "hash": "0x06d4650284cc43b2ee0a6bc62455676e86b574779bd07cbc50396743bf6abcfc", + "network": "starknet", + "status": "success", + "transactionIndex": 2 + }, + "transferSummary": [ + { + "asset": { + "amount": "25000000000000000", + "fiatAmount": { + "currency": "USD", + "currencyAmount": 62.5761 + }, + "tokenAddress": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + "type": "token" + }, + "sent": false + } + ], + "transfers": [ + { + "asset": { + "amount": "25000000000000000", + "fiatAmount": { + "currency": "USD", + "currencyAmount": 62.5761 + }, + "tokenAddress": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + "type": "token" + }, + "counterparty": "0x324b04e5a4270605007334372ce455c47581e51cdd091559df2739d1c4e2677", + "counterpartyNetwork": "starknet", + "leg": "credit", + "type": "payment" + } + ], + "triggeredBalanceUpdate": false, + "txSender": "0x0324b04e5a4270605007334372ce455c47581e51cdd091559df2739d1c4e2677", + "type": "payment", + "wallet": "0x051cda0876cb58b05d9a6d9da52b00b8d04733028def56668867e3bffd9fac47" + } + ] + } + } +} diff --git a/packages/extension/src/shared/storage/__new/__test__/inmemoryImplementations.ts b/packages/extension/src/shared/storage/__new/__test__/inmemoryImplementations.ts index 4320d5577..77bf0d75e 100644 --- a/packages/extension/src/shared/storage/__new/__test__/inmemoryImplementations.ts +++ b/packages/extension/src/shared/storage/__new/__test__/inmemoryImplementations.ts @@ -1,6 +1,6 @@ import { isArray, isEqual, isFunction, isString } from "lodash-es" -import { +import type { AllowArray, AllowPromise, IObjectStore, @@ -12,7 +12,7 @@ import { StorageChange, UpsertResult, } from "../interface" -import { IKeyValueStorage } from "../.." +import type { IKeyValueStorage } from "../.." export class InMemoryObjectStore implements IObjectStore { public namespace: string diff --git a/packages/extension/src/shared/storage/__new/__test__/keyvalue.test.ts b/packages/extension/src/shared/storage/__new/__test__/keyvalue.test.ts index 23b235610..46dfb33d6 100644 --- a/packages/extension/src/shared/storage/__new/__test__/keyvalue.test.ts +++ b/packages/extension/src/shared/storage/__new/__test__/keyvalue.test.ts @@ -1,7 +1,7 @@ import { MockStorage } from "../../__test__/chrome-storage.mock" import { KeyValueStorage } from "../../keyvalue" -import { AreaName, StorageArea } from "../../types" -import { IObjectStore } from "../interface" +import type { AreaName, StorageArea } from "../../types" +import type { IObjectStore } from "../interface" import { adaptKeyValue } from "../keyvalue" type TestData = { diff --git a/packages/extension/src/shared/storage/__new/__test__/mockFunctionImplementation.ts b/packages/extension/src/shared/storage/__new/__test__/mockFunctionImplementation.ts index 9def2c83d..18888288a 100644 --- a/packages/extension/src/shared/storage/__new/__test__/mockFunctionImplementation.ts +++ b/packages/extension/src/shared/storage/__new/__test__/mockFunctionImplementation.ts @@ -1,6 +1,6 @@ import { vi } from "vitest" -import { IObjectStore, IRepository } from "../interface" +import type { IObjectStore, IRepository } from "../interface" export class MockFnObjectStore implements IObjectStore { public namespace = "test:mockFnObjectStore" diff --git a/packages/extension/src/shared/storage/__new/__test__/object.test.ts b/packages/extension/src/shared/storage/__new/__test__/object.test.ts index 25d783134..18763c1b2 100644 --- a/packages/extension/src/shared/storage/__new/__test__/object.test.ts +++ b/packages/extension/src/shared/storage/__new/__test__/object.test.ts @@ -1,4 +1,4 @@ -import { IObjectStore } from "../interface" +import type { IObjectStore } from "../interface" import { adaptObjectStorage } from "../object" import { ObjectStorage } from "../../object" diff --git a/packages/extension/src/shared/storage/__new/chrome.ts b/packages/extension/src/shared/storage/__new/chrome.ts index 4ea267645..01bfb37da 100644 --- a/packages/extension/src/shared/storage/__new/chrome.ts +++ b/packages/extension/src/shared/storage/__new/chrome.ts @@ -11,7 +11,7 @@ import type { StorageChange, UpsertResult, } from "./interface" -import { DeepPick } from "../../types/deepPick" +import type { DeepPick } from "../../types/deepPick" interface ChromeRepositoryOptions { areaName: chrome.storage.AreaName diff --git a/packages/extension/src/shared/storage/__new/keyvalue.ts b/packages/extension/src/shared/storage/__new/keyvalue.ts index eb2fbb14b..ff2873962 100644 --- a/packages/extension/src/shared/storage/__new/keyvalue.ts +++ b/packages/extension/src/shared/storage/__new/keyvalue.ts @@ -1,7 +1,7 @@ import { debounce } from "lodash-es" -import { KeyValueStorage } from "../keyvalue" -import { IObjectStore, StorageChange } from "./interface" +import type { KeyValueStorage } from "../keyvalue" +import type { IObjectStore, StorageChange } from "./interface" export function adaptKeyValue>( storage: KeyValueStorage, diff --git a/packages/extension/src/shared/storage/__new/object.ts b/packages/extension/src/shared/storage/__new/object.ts index b7c5abfde..87944e37a 100644 --- a/packages/extension/src/shared/storage/__new/object.ts +++ b/packages/extension/src/shared/storage/__new/object.ts @@ -1,5 +1,5 @@ -import { IObjectStore } from "./interface" -import { IObjectStorage } from ".." +import type { IObjectStore } from "./interface" +import type { IObjectStorage } from ".." export function adaptObjectStorage( storage: IObjectStorage, @@ -17,7 +17,7 @@ export function adaptObjectStorage( subscribe(callback) { // this is never fired, need to investigate and fix return storage.subscribe((_value, changeSet) => { - callback(changeSet) + void callback(changeSet) }) }, } diff --git a/packages/extension/src/shared/storage/__new/prune.test.ts b/packages/extension/src/shared/storage/__new/prune.test.ts index 5f9f66335..dcc9166a3 100644 --- a/packages/extension/src/shared/storage/__new/prune.test.ts +++ b/packages/extension/src/shared/storage/__new/prune.test.ts @@ -1,8 +1,7 @@ import { describe, expect, vi } from "vitest" +import type { IMinimalStorage, Pattern } from "./prune" import { - IMinimalStorage, - Pattern, copyObjectToStorage, copyStorageToObject, pruneStorageData, diff --git a/packages/extension/src/shared/storage/__new/prune.ts b/packages/extension/src/shared/storage/__new/prune.ts index 07b262be1..b9b4fd861 100644 --- a/packages/extension/src/shared/storage/__new/prune.ts +++ b/packages/extension/src/shared/storage/__new/prune.ts @@ -3,8 +3,10 @@ import { ARGENT_API_BASE_URL, ARGENT_EXPLORER_BASE_URL, } from "../../api/constants" -import { Transaction, getInFlightTransactions } from "../../transactions" +import type { Transaction } from "../../transactions" +import { getInFlightTransactions } from "../../transactions" +// eslint-disable-next-line @typescript-eslint/no-empty-object-type export interface IMinimalStorage extends Pick {} @@ -38,7 +40,7 @@ export function pruneTransactions(value: string) { const transactions: Transaction[] = JSON.parse(value) const prunedTransactions = getInFlightTransactions(transactions) return JSON.stringify(prunedTransactions) - } catch (e) { + } catch { // ignore parsing error } return value diff --git a/packages/extension/src/shared/storage/__new/replaceValueInStorage.test.ts b/packages/extension/src/shared/storage/__new/replaceValueInStorage.test.ts new file mode 100644 index 000000000..1c31b6d01 --- /dev/null +++ b/packages/extension/src/shared/storage/__new/replaceValueInStorage.test.ts @@ -0,0 +1,90 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" +import browser from "webextension-polyfill" +import mockStorageData from "./__test__/__fixtures__/storage.json" +import { replaceValueInStorage } from "./replaceValueInStorage" + +import * as utils from "./utils" + +describe("Replace value in browser storage", () => { + beforeEach(async () => { + vi.spyOn(browser.storage.local, "set").mockResolvedValue() + // mocking the implementation and not the return value because get has 3 overloads and not all return a value, so the compiler complains + // eslint-disable-next-line @typescript-eslint/no-misused-promises + vi.spyOn(browser.storage.local, "get").mockImplementation(() => + Promise.resolve(mockStorageData), + ) + }) + + it("should replace id in browser storage", async () => { + await replaceValueInStorage( + mockStorageData["core:accounts:inner"][0].id, + "newValue", + ["id"], + ) + expect(browser.storage.local.get).toHaveBeenCalled() + expect(browser.storage.local.set).not.toHaveBeenCalledWith(mockStorageData) + }) + + it("should not replace because value does not exist", async () => { + await replaceValueInStorage("oldValue", "newValue") + expect(browser.storage.local.get).toHaveBeenCalled() + expect(browser.storage.local.set).toHaveBeenCalledWith(mockStorageData) + }) + + it("should restore the original storage data in case of an error when saving ", async () => { + const mockData = { + key1: "oldValue", + key2: "value2", + nested: { + key3: "oldValue", + key4: "value3", + }, + array: ["oldValue", "value4"], + } + vi.spyOn(browser.storage.local, "set").mockRejectedValueOnce( + new Error("Set error"), + ) + // eslint-disable-next-line @typescript-eslint/no-misused-promises + vi.spyOn(browser.storage.local, "get").mockImplementation(() => + Promise.resolve(mockData), + ) + await replaceValueInStorage("oldValue", "newValue") + + expect(browser.storage.local.get).toHaveBeenCalled() + expect(browser.storage.local.set).toHaveBeenCalledTimes(2) + expect(browser.storage.local.set).toHaveBeenNthCalledWith(1, { + key1: "newValue", + key2: "value2", + nested: { + key3: "newValue", + key4: "value3", + }, + array: ["newValue", "value4"], + }) + expect(browser.storage.local.set).toHaveBeenNthCalledWith(2, mockData) + }) + + it("should not save the data in case of error when replacing the values", async () => { + const mockData = { + key1: "oldValue", + key2: "value2", + nested: { + key3: "oldValue", + key4: "value3", + }, + array: ["oldValue", "value4"], + } + // eslint-disable-next-line @typescript-eslint/no-misused-promises + vi.spyOn(browser.storage.local, "get").mockImplementation(() => + Promise.resolve(mockData), + ) + vi.spyOn(utils, "replaceValueRecursively").mockImplementationOnce(() => { + throw new Error("Failed to replace value") + }) + + await replaceValueInStorage("oldValue", "newValue") + + expect(browser.storage.local.get).toHaveBeenCalled() + expect(browser.storage.local.set).not.toHaveBeenCalled() + }) +}) diff --git a/packages/extension/src/shared/storage/__new/replaceValueInStorage.ts b/packages/extension/src/shared/storage/__new/replaceValueInStorage.ts new file mode 100644 index 000000000..75e76580e --- /dev/null +++ b/packages/extension/src/shared/storage/__new/replaceValueInStorage.ts @@ -0,0 +1,62 @@ +import browser from "webextension-polyfill" +import { browserExtensionSentryWithScope } from "../../sentry/scope" +import { replaceValueRecursively } from "./utils" + +type StorageData = { [key: string]: any } + +/** Function to replace a value with a new value across all records in storage + * Optionally, the replacement can be limited to specific keys + * !! Should be used with caution as it modifies the storage data directly + */ +export async function replaceValueInStorage( + oldValue: any, + newValue: any, + keys?: string[], +): Promise { + let originalStorageData: StorageData | null = null + let isSaving = false + try { + // Get all data from storage + originalStorageData = await browser.storage.local.get() + + // Clone the original storage data to avoid modifying it directly + const storageData: StorageData = JSON.parse( + JSON.stringify(originalStorageData), + ) + // Recursively replace the value in the entire storage data + replaceValueRecursively(storageData, oldValue, newValue, keys) + + isSaving = true + // Save the modified data back to storage + await browser.storage.local.set(storageData) + isSaving = false + } catch (error) { + browserExtensionSentryWithScope((scope) => { + scope.setLevel("warning") + scope.setExtra("replacedAccountId", { oldValue, newValue }) + scope.captureException( + new Error( + `Error replacing value in storage. Restoring to original. ${error}`, + ), + ) + }) + console.error("Error replacing value in storage:", error) + + // Restore the original storage data in case of an error when saving + if (originalStorageData && isSaving) { + try { + await browser.storage.local.set(originalStorageData) + } catch (restoreError) { + browserExtensionSentryWithScope((scope) => { + scope.setLevel("error") + scope.setExtra("replacedAccountId", { oldValue, newValue }) + scope.captureException( + new Error( + `Error restoring the original storage data: ${restoreError}`, + ), + ) + }) + } + } + } +} diff --git a/packages/extension/src/shared/storage/__new/repository.ts b/packages/extension/src/shared/storage/__new/repository.ts index 9d87e7709..e58b00ff5 100644 --- a/packages/extension/src/shared/storage/__new/repository.ts +++ b/packages/extension/src/shared/storage/__new/repository.ts @@ -1,4 +1,4 @@ -import { +import type { AllowArray, AllowPromise, IRepository, @@ -7,7 +7,7 @@ import { StorageChange, UpsertResult, } from "./interface" -import { ArrayStorage } from ".." +import type { ArrayStorage } from ".." export function adaptArrayStorage(storage: ArrayStorage): IRepository { return { diff --git a/packages/extension/src/shared/storage/__new/utils.test.ts b/packages/extension/src/shared/storage/__new/utils.test.ts new file mode 100644 index 000000000..aaf89bf7b --- /dev/null +++ b/packages/extension/src/shared/storage/__new/utils.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from "vitest" +import mockStorageData from "./__test__/__fixtures__/storage.json" + +import { containsValue, replaceValueRecursively } from "./utils" + +describe("replaceValueRecursively", () => { + it("should replace the old value with the new value in an object", () => { + const obj = { + key1: "value1", + key2: "value2", + nested: { + key3: "value1", + key4: "value3", + }, + } + const expected = { + key1: "newValue", + key2: "value2", + nested: { + key3: "newValue", + key4: "value3", + }, + } + + replaceValueRecursively(obj, "value1", "newValue") + + expect(obj).toEqual(expected) + }) + + it("should replace the old value with the new value in an array", () => { + const arr = ["value1", "value2", "value1"] + const expected = ["newValue", "value2", "newValue"] + + replaceValueRecursively(arr, "value1", "newValue") + + expect(arr).toEqual(expected) + }) + + it("should replace the old value with the new value in a nested array", () => { + const obj = { + key1: ["value1", "value2", { key2: "value1" }], + } + const expected = { + key1: ["newValue", "value2", { key2: "newValue" }], + } + + replaceValueRecursively(obj, "value1", "newValue") + + expect(obj).toEqual(expected) + }) + + it("should not modify the object if the old value is not found", () => { + const obj = { + key1: "value1", + key2: "value2", + nested: { + key3: "value1", + key4: "value3", + }, + } + const expected = { ...obj } + + replaceValueRecursively(obj, "nonExistentValue", "newValue") + + expect(obj).toEqual(expected) + }) + + it("should handle null and undefined values correctly", () => { + const obj = { + key1: null, + key2: undefined, + key3: "value1", + } + const expected = { + key1: null, + key2: undefined, + key3: "newValue", + } + + replaceValueRecursively(obj, "value1", "newValue") + + expect(obj).toEqual(expected) + }) + + it("should replace the id of the account in storage", () => { + const id = mockStorageData["core:accounts:inner"][0].id + const newId = "newAccountId::sepolia-alpha::local_secret::0" + const storageDataCopy = JSON.parse(JSON.stringify(mockStorageData)) + + replaceValueRecursively(storageDataCopy, id, newId, ["id"]) + + expect(storageDataCopy).not.toEqual(mockStorageData) + + // verifies that the old id is replaced with the new id by comparing the stringified versions + const storageDataCopyAsString = JSON.stringify(storageDataCopy) + expect(storageDataCopyAsString).toContain(newId) + expect(storageDataCopyAsString).not.toContain(id) + + const idMatchesInObject = JSON.stringify(mockStorageData).match( + new RegExp(id, "g"), + ) + const idCount = idMatchesInObject ? idMatchesInObject.length : 0 + + const newIdMatchesInObject = storageDataCopyAsString.match( + new RegExp(newId, "g"), + ) + const newIdCount = newIdMatchesInObject ? newIdMatchesInObject.length : 0 + + expect(idCount).toBe(newIdCount) + + // verifies that the old id is replaced with the new id by comparing the object versions + const storageDataCopyAsObject = JSON.parse(storageDataCopyAsString) + expect(storageDataCopyAsObject).toEqual(storageDataCopy) + expect(storageDataCopyAsObject).not.toEqual(mockStorageData) + + expect(containsValue(storageDataCopy, id)).toBe(false) + expect(containsValue(storageDataCopy, newId)).toBe(true) + }) +}) diff --git a/packages/extension/src/shared/storage/__new/utils.ts b/packages/extension/src/shared/storage/__new/utils.ts new file mode 100644 index 000000000..25bf4ea92 --- /dev/null +++ b/packages/extension/src/shared/storage/__new/utils.ts @@ -0,0 +1,55 @@ +/** Function to recursively replace the value in an object or array + * Optionally, the replacement can be limited to specific keys + */ +export function replaceValueRecursively( + obj: any, + oldValue: any, + newValue: any, + keys?: string[], +): void { + if (typeof obj === "object" && obj !== null) { + for (const key in obj) { + if (typeof obj[key] === "object" && obj[key] !== null) { + // Recursively call for nested objects or arrays + replaceValueRecursively(obj[key], oldValue, newValue, keys) + } else if ((!keys || keys.includes(key)) && obj[key] === oldValue) { + // Replace the value if it matches oldValue + obj[key] = newValue + } + } + } else if (Array.isArray(obj)) { + // Iterate through array elements + obj.forEach((item, index) => { + if (typeof item === "object" && item !== null) { + replaceValueRecursively(item, oldValue, newValue, keys) + } else if (item === oldValue) { + obj[index] = newValue + } + }) + } +} + +export function containsValue(obj: any, value: any): boolean { + if (typeof obj === "object" && obj !== null) { + for (const key in obj) { + if (typeof obj[key] === "object" && obj[key] !== null) { + if (containsValue(obj[key], value)) { + return true + } + } else if (obj[key] === value) { + return true + } + } + } else if (Array.isArray(obj)) { + for (const item of obj) { + if (typeof item === "object" && item !== null) { + if (containsValue(item, value)) { + return true + } + } else if (item === value) { + return true + } + } + } + return false +} diff --git a/packages/extension/src/shared/storage/__test__/chrome-storage.mock.ts b/packages/extension/src/shared/storage/__test__/chrome-storage.mock.ts index 044d725fa..c1850261c 100644 --- a/packages/extension/src/shared/storage/__test__/chrome-storage.mock.ts +++ b/packages/extension/src/shared/storage/__test__/chrome-storage.mock.ts @@ -1,7 +1,8 @@ -import { WildcardHandler, mittx } from "starknetkit/window" +import type { WildcardHandler } from "@argent/x-window" +import { mittx } from "@argent/x-window" import { isFunction } from "lodash-es" -import { +import type { AreaName, Implementations, OnChanged, diff --git a/packages/extension/src/shared/storage/__test__/keyvalue.test.ts b/packages/extension/src/shared/storage/__test__/keyvalue.test.ts index 23f618c97..793449ece 100644 --- a/packages/extension/src/shared/storage/__test__/keyvalue.test.ts +++ b/packages/extension/src/shared/storage/__test__/keyvalue.test.ts @@ -1,7 +1,8 @@ import { describe, expect, test, vi } from "vitest" -import { IKeyValueStorage, KeyValueStorage } from "../keyvalue" -import { AreaName, StorageArea } from "../types" +import type { IKeyValueStorage } from "../keyvalue" +import { KeyValueStorage } from "../keyvalue" +import type { AreaName, StorageArea } from "../types" import { MockStorage } from "./chrome-storage.mock" interface IStore { diff --git a/packages/extension/src/shared/storage/__test__/object.test.ts b/packages/extension/src/shared/storage/__test__/object.test.ts index cec06b3ab..d5d80fd6e 100644 --- a/packages/extension/src/shared/storage/__test__/object.test.ts +++ b/packages/extension/src/shared/storage/__test__/object.test.ts @@ -1,4 +1,5 @@ -import { IObjectStorage, ObjectStorage } from "../object" +import type { IObjectStorage } from "../object" +import { ObjectStorage } from "../object" describe("full storage flow with object", () => { const defaults = { foo: "bar" } diff --git a/packages/extension/src/shared/storage/array.ts b/packages/extension/src/shared/storage/array.ts index e63b4e5db..bcd5b6962 100644 --- a/packages/extension/src/shared/storage/array.ts +++ b/packages/extension/src/shared/storage/array.ts @@ -1,8 +1,10 @@ -import { differenceWith, isEqual, isFunction } from "lodash-es" +import { isArray, isEqual, isFunction, partition } from "lodash-es" -import { ObjectStorage, ObjectStorageOptions } from "./object" -import { StorageOptionsOrNameSpace, getOptionsWithDefaults } from "./options" -import { +import type { ObjectStorageOptions } from "./object" +import { ObjectStorage } from "./object" +import type { StorageOptionsOrNameSpace } from "./options" +import { getOptionsWithDefaults } from "./options" +import type { AllowArray, AllowPromise, AreaName, @@ -97,14 +99,17 @@ export class ArrayStorage implements IArrayStorage { */ public async remove(value: AllowArray | SelectorFn): Promise { const all = await this.get() - const valuesToRemove = isFunction(value) - ? await this.get(value) - : Array.isArray(value) - ? value - : [value] - const newAll = differenceWith(all, valuesToRemove, this.compare) - await this.storageImplementation.set(newAll) - return valuesToRemove + const compareFn = this.compare.bind(this) + + const selector = isFunction(value) + ? (item: T) => !value(item) + : isArray(value) + ? (item: T) => !value.some((v) => compareFn(v, item)) + : (item: T) => !compareFn(value, item) + + const [keptValues, removedValues] = partition(all, selector) + await this.storageImplementation.set(keptValues) + return removedValues } public subscribe( diff --git a/packages/extension/src/shared/storage/keyvalue.ts b/packages/extension/src/shared/storage/keyvalue.ts index 71d1abe8c..7c537da22 100644 --- a/packages/extension/src/shared/storage/keyvalue.ts +++ b/packages/extension/src/shared/storage/keyvalue.ts @@ -1,8 +1,9 @@ import browser from "webextension-polyfill" import { MockStorage } from "./__test__/chrome-storage.mock" -import { StorageOptionsOrNameSpace, getOptionsWithDefaults } from "./options" -import { +import type { StorageOptionsOrNameSpace } from "./options" +import { getOptionsWithDefaults } from "./options" +import type { AllowPromise, AreaName, BaseStorage, @@ -57,7 +58,7 @@ export class KeyValueStorage< if (!this.storageImplementation) { throw new Error() } - } catch (e) { + } catch { if (options.areaName === "session") { const { manifest_version } = browser.runtime.getManifest() if (manifest_version === 2) { diff --git a/packages/extension/src/shared/storage/object.ts b/packages/extension/src/shared/storage/object.ts index 99db3a5ec..c28d65a25 100644 --- a/packages/extension/src/shared/storage/object.ts +++ b/packages/extension/src/shared/storage/object.ts @@ -1,8 +1,8 @@ import { cloneDeep, isPlainObject, merge } from "lodash-es" import { KeyValueStorage } from "./keyvalue" -import { StorageOptions, StorageOptionsOrNameSpace } from "./options" -import { AreaName, BaseStorage, StorageChange } from "./types" +import type { StorageOptions, StorageOptionsOrNameSpace } from "./options" +import type { AreaName, BaseStorage, StorageChange } from "./types" type AllowPromise = T | Promise diff --git a/packages/extension/src/shared/storage/options.ts b/packages/extension/src/shared/storage/options.ts index 53dad5acb..c7f5db5d9 100644 --- a/packages/extension/src/shared/storage/options.ts +++ b/packages/extension/src/shared/storage/options.ts @@ -1,6 +1,6 @@ import { isString } from "lodash-es" -import { +import type { AreaName, OnlyOptionalPropertiesOf, RequiredPropertiesOf, diff --git a/packages/extension/src/shared/storage/types.ts b/packages/extension/src/shared/storage/types.ts index c56e5e49a..a6647aaf0 100644 --- a/packages/extension/src/shared/storage/types.ts +++ b/packages/extension/src/shared/storage/types.ts @@ -1,4 +1,4 @@ -import browser from "webextension-polyfill" +import type browser from "webextension-polyfill" export type AllowPromise = T | Promise export type AllowArray = T | T[] diff --git a/packages/extension/src/shared/swap/service/ISharedSwapService.ts b/packages/extension/src/shared/swap/service/ISharedSwapService.ts index 415c2a3a7..c0563aa3c 100644 --- a/packages/extension/src/shared/swap/service/ISharedSwapService.ts +++ b/packages/extension/src/shared/swap/service/ISharedSwapService.ts @@ -1,13 +1,14 @@ -import { SwapOrderResponse } from "../model/order.model" -import { SwapQuoteResponse } from "../model/quote.model" -import { Trade } from "../model/trade.model" +import type { SwapOrderResponse } from "../model/order.model" +import type { SwapQuoteResponse } from "../model/quote.model" +import type { Trade } from "../model/trade.model" export interface ISharedSwapService { getSwapQuoteForPay: ( payTokenAddress: string, receiveTokenAddress: string, - payAmount: string, accountAddress: string, + sellAmount?: string, + buyAmount?: string, ) => Promise getSwapTradeFromQuote: ( quote: SwapQuoteResponse, diff --git a/packages/extension/src/shared/swap/service/SharedSwapService.test.ts b/packages/extension/src/shared/swap/service/SharedSwapService.test.ts index 23a89cf40..c9f0ea26b 100644 --- a/packages/extension/src/shared/swap/service/SharedSwapService.test.ts +++ b/packages/extension/src/shared/swap/service/SharedSwapService.test.ts @@ -3,7 +3,7 @@ import { sampleQuoteJson } from "./quote.mock" import { HttpResponse, http } from "msw" import { setupServer } from "msw/node" import { SharedSwapService } from "./SharedSwapService" -import { ISharedSwapService } from "./ISharedSwapService" +import type { ISharedSwapService } from "./ISharedSwapService" import { getMockTrade } from "../../../../test/trade.mock" import { getMockToken } from "../../../../test/token.mock" import { SwapError } from "../../errors/swap" diff --git a/packages/extension/src/shared/swap/service/SharedSwapService.ts b/packages/extension/src/shared/swap/service/SharedSwapService.ts index d193f24e6..7557bdeae 100644 --- a/packages/extension/src/shared/swap/service/SharedSwapService.ts +++ b/packages/extension/src/shared/swap/service/SharedSwapService.ts @@ -1,19 +1,18 @@ import urlJoin from "url-join" -import { IHttpService } from "@argent/x-shared" -import { INetworkService } from "../../network/service/INetworkService" -import { ITokenService } from "../../token/__new/service/ITokenService" +import type { IHttpService } from "@argent/x-shared" +import type { INetworkService } from "../../network/service/INetworkService" +import type { ITokenService } from "../../token/__new/service/ITokenService" import { urlWithQuery } from "../../utils/url" +import type { SwapOrderResponse } from "../model/order.model" import { SwapOrderRequestSchema, - SwapOrderResponse, SwapOrderResponseSchema, } from "../model/order.model" -import { - SwapQuoteResponse, - SwapQuoteResponseSchema, -} from "../model/quote.model" -import { Trade, TradeSchema } from "../model/trade.model" -import { ISharedSwapService } from "./ISharedSwapService" +import type { SwapQuoteResponse } from "../model/quote.model" +import { SwapQuoteResponseSchema } from "../model/quote.model" +import type { Trade } from "../model/trade.model" +import { TradeSchema } from "../model/trade.model" +import type { ISharedSwapService } from "./ISharedSwapService" import { SwapError } from "../../errors/swap" import { calculateTotalFee } from "../utils" import { ampli } from "../../analytics" @@ -39,24 +38,31 @@ export class SharedSwapService implements ISharedSwapService { async getSwapQuoteForPay( payTokenAddress: string, receiveTokenAddress: string, - payAmount: string, accountAddress: string, + sellAmount?: string, + buyAmount?: string, ): Promise { - const quoteUrl = urlWithQuery(this.swapQuoteUrl, { + const queryParams: Record = { chain: "starknet", currency: "USD", sellToken: payTokenAddress, buyToken: receiveTokenAddress, - sellAmount: payAmount, accountAddress, - }) + } + + if (sellAmount) { + queryParams.sellAmount = sellAmount + } else { + queryParams.buyAmount = buyAmount + } + + const quoteUrl = urlWithQuery(this.swapQuoteUrl, queryParams) try { const response = await this.httpService.get(quoteUrl) const quoteResult = await SwapQuoteResponseSchema.parseAsync(response) return quoteResult } catch (error) { - console.error(error) ampli.swapQuoteFailed({ "error type": `${error}` as any, "wallet platform": "browser extension", @@ -135,8 +141,7 @@ export class SharedSwapService implements ISharedSwapService { const parsedResponse = await SwapOrderResponseSchema.parseAsync(response) return parsedResponse - } catch (error) { - console.error(error) + } catch { throw new SwapError({ code: "INVALID_SWAP_ORDER_RESPONSE" }) } } diff --git a/packages/extension/src/shared/swap/utils/totalFee.ts b/packages/extension/src/shared/swap/utils/totalFee.ts index 37bdc78a2..c24011c4f 100644 --- a/packages/extension/src/shared/swap/utils/totalFee.ts +++ b/packages/extension/src/shared/swap/utils/totalFee.ts @@ -1,6 +1,6 @@ import { bigDecimal } from "@argent/x-shared" -import { SwapQuoteResponse } from "../model/quote.model" -import { Trade } from "../model/trade.model" +import type { SwapQuoteResponse } from "../model/quote.model" +import type { Trade } from "../model/trade.model" type TotalTradeFee = Pick< Trade, diff --git a/packages/extension/src/shared/test.utils.ts b/packages/extension/src/shared/test.utils.ts new file mode 100644 index 000000000..a8e8425d6 --- /dev/null +++ b/packages/extension/src/shared/test.utils.ts @@ -0,0 +1,152 @@ +import type { IHttpService } from "@argent/x-shared" +import { + MockFnObjectStore, + MockFnRepository, +} from "./storage/__new/__test__/mockFunctionImplementation" +import type { IObjectStore } from "./storage/__new/interface" +import type { WalletStorageProps } from "./wallet/walletStore" +import type { BaseMultisigWalletAccount, WalletAccount } from "./wallet.model" +import type { WalletSession } from "./account/service/accountSharedService/WalletAccountSharedService" +import { WalletAccountSharedService } from "./account/service/accountSharedService/WalletAccountSharedService" +import type { IPKStore } from "./accountImport/types" +import type { PendingMultisig } from "./multisig/types" +import { PKManager } from "./accountImport/pkManager/PKManager" +import { MultisigBackendService } from "./multisig/service/backend/MultisigBackendService" +import { StarknetChainService } from "./chain/service/StarknetChainService" +import { AccountService } from "./account/service/accountService/AccountService" +import { LedgerSharedService } from "./ledger/service/LedgerSharedService" +import type { ISettingsStorage } from "./settings/types" + +const isDev = true +const isTest = true +const isDevOrTest = isDev || isTest +export const SCRYPT_N_TEST = isDevOrTest ? 64 : 262144 +const defaultKeyValueStorage = { + get: vi.fn(), + set: vi.fn(), + delete: vi.fn(), + subscribe: vi.fn(), + namespace: "", + areaName: "local", + defaults: {}, +} + +export const httpServiceMock = { + post: vi.fn(), + put: vi.fn(), + get: vi.fn(), + delete: vi.fn(), +} as IHttpService + +// Replace with class store after migration +const defaultArrayStorage = new MockFnRepository() + +const defaultObjectStorage = new MockFnObjectStore() + +export const getKeyValueStorage = >( + overrides?: Partial>, +): IObjectStore => { + return { + ...defaultKeyValueStorage, + ...overrides, + } as IObjectStore +} + +const getArrayStorage = ( + overrides?: Partial>, +): MockFnRepository => { + return { + ...defaultArrayStorage, + ...overrides, + } as MockFnRepository +} + +const getObjectStorage = ( + overrides?: Partial>, +): IObjectStore => { + return { + ...defaultObjectStorage, + ...overrides, + } as IObjectStore +} + +export const getStoreMock = (overrides?: IObjectStore) => + getKeyValueStorage(overrides) + +export const getWalletStoreMock = ( + overrides?: Partial>, +) => getArrayStorage(overrides) + +export const getSessionStoreMock = ( + overrides?: Partial>, +) => getObjectStorage(overrides) + +export const getPKStoreMock = (overrides?: Partial>) => + getKeyValueStorage(overrides) + +export const getMultisigStoreMock = ( + overrides?: Partial>, +) => getArrayStorage(overrides) + +export const getPendingMultisigStoreMock = ( + overrides?: Partial>, +) => getArrayStorage(overrides) + +export const getAccountStoreMock = ( + overrides?: Partial>, +) => getArrayStorage(overrides) + +export const getSettingsStoreMock = ( + overrides?: Partial>, +) => getKeyValueStorage(overrides) + +export const loadContractsMock = vi.fn() +export const networkServiceMock = { + getById: vi.fn(), +} + +export const pkManagerMock = new PKManager(getPKStoreMock(), SCRYPT_N_TEST) + +export const emitterMock = { + anyEvent: vi.fn(), + bindMethods: vi.fn(), + clearListeners: vi.fn(), + debug: vi.fn(), + emit: vi.fn(), + emitSerial: vi.fn(), + events: vi.fn(), + listenerCount: vi.fn(), + off: vi.fn(), + offAny: vi.fn(), + on: vi.fn(), + onAny: vi.fn(), + once: vi.fn(), +} + +export const multisigBackendServiceMock = new MultisigBackendService( + "mockBackendUrl", +) + +export const chainServiceMock = new StarknetChainService(networkServiceMock) + +export const accountServiceMock = new AccountService( + emitterMock, + chainServiceMock, + getAccountStoreMock(), + pkManagerMock, +) + +export const accountSharedServiceMock = new WalletAccountSharedService( + getStoreMock(), + getWalletStoreMock(), + getSessionStoreMock(), + getMultisigStoreMock(), + getPendingMultisigStoreMock(), + httpServiceMock, + accountServiceMock, +) + +export const ledgerServiceMock = new LedgerSharedService( + networkServiceMock, + multisigBackendServiceMock, +) diff --git a/packages/extension/src/shared/token/__deprecated/storage.ts b/packages/extension/src/shared/token/__deprecated/storage.ts index 644b53946..54c6cf3d9 100644 --- a/packages/extension/src/shared/token/__deprecated/storage.ts +++ b/packages/extension/src/shared/token/__deprecated/storage.ts @@ -1,7 +1,8 @@ import { z } from "zod" import { ArrayStorage } from "../../storage" -import { BaseToken, BaseTokenSchema, Token } from "./type" +import type { BaseToken, Token } from "./type" +import { BaseTokenSchema } from "./type" import { equalToken, parsedDeprecatedTokens } from "./utils" export const tokenStore = new ArrayStorage(parsedDeprecatedTokens, { diff --git a/packages/extension/src/shared/token/__deprecated/type.ts b/packages/extension/src/shared/token/__deprecated/type.ts index 2624d7925..a087813c6 100644 --- a/packages/extension/src/shared/token/__deprecated/type.ts +++ b/packages/extension/src/shared/token/__deprecated/type.ts @@ -27,6 +27,7 @@ const RequestTokenSchema = z.object({ decimals: z.coerce.number().optional(), }) +// eslint-disable-next-line @typescript-eslint/no-unused-vars const TokenSchema = RequestTokenSchema.required().extend({ image: z.string().optional(), showAlways: z.boolean().optional(), diff --git a/packages/extension/src/shared/token/__deprecated/utils.ts b/packages/extension/src/shared/token/__deprecated/utils.ts index 7f64226e8..48d39aab1 100644 --- a/packages/extension/src/shared/token/__deprecated/utils.ts +++ b/packages/extension/src/shared/token/__deprecated/utils.ts @@ -1,7 +1,7 @@ import { addressSchema, isEqualAddress } from "@argent/x-shared" import defaultTokens from "../../../assets/default-tokens.json" -import { BaseToken, Token } from "./type" +import type { BaseToken, Token } from "./type" export const equalToken = (a: BaseToken, b: BaseToken) => a.networkId === b.networkId && isEqualAddress(a.address, b.address) diff --git a/packages/extension/src/shared/token/__fixtures__/mockTokensWithBalance.ts b/packages/extension/src/shared/token/__fixtures__/mockTokensWithBalance.ts index bb08d35e8..1b511bc1d 100644 --- a/packages/extension/src/shared/token/__fixtures__/mockTokensWithBalance.ts +++ b/packages/extension/src/shared/token/__fixtures__/mockTokensWithBalance.ts @@ -1,4 +1,4 @@ -import { Address } from "@argent/x-shared" +import type { Address } from "@argent/x-shared" import mockTokensWithBalanceRaw from "../../../../test/__fixtures__/tokens-with-balance.mock.json" import type { TokenWithOptionalBigIntBalance } from "../__new/types/tokenBalance.model" diff --git a/packages/extension/src/shared/token/__new/constants.ts b/packages/extension/src/shared/token/__new/constants.ts index 5e8175525..206ae09aa 100644 --- a/packages/extension/src/shared/token/__new/constants.ts +++ b/packages/extension/src/shared/token/__new/constants.ts @@ -1,5 +1,5 @@ import { constants } from "starknet" -import { BaseToken } from "./types/token.model" +import type { BaseToken } from "./types/token.model" export const ETH: Record = { [constants.StarknetChainId.SN_MAIN]: { diff --git a/packages/extension/src/shared/token/__new/service/ITokenService.ts b/packages/extension/src/shared/token/__new/service/ITokenService.ts index c59876318..e201710d8 100644 --- a/packages/extension/src/shared/token/__new/service/ITokenService.ts +++ b/packages/extension/src/shared/token/__new/service/ITokenService.ts @@ -1,13 +1,19 @@ -import { AllowArray, SelectorFn } from "../../../storage/__new/interface" -import { BaseWalletAccount } from "../../../wallet.model" -import { BaseToken, Token } from "../types/token.model" -import { BaseTokenWithBalance } from "../types/tokenBalance.model" -import { ApiTokenInfo } from "@argent/x-shared" -import { +import type { AllowArray, SelectorFn } from "../../../storage/__new/interface" +import type { BaseWalletAccount } from "../../../wallet.model" +import type { BaseToken, Token } from "../types/token.model" +import type { BaseTokenWithBalance } from "../types/tokenBalance.model" +import type { ApiTokenInfo } from "@argent/x-shared" +import type { TokenPriceDetails, TokenWithBalanceAndPrice, } from "../types/tokenPrice.model" +export type FetchedTokenDetails = { + name: string + symbol: string + decimals: number +} + /** * ITokenService interface provides methods for managing tokens, including storage methods, fetch methods, and get methods. */ @@ -56,20 +62,25 @@ export interface ITokenService { * getToken: Retrieve a token from the storage * getTokens: If a selector is provided, only tokens for that satisfies the selector is returned. * Otherwise, all tokens are returned. - * getTokenBalancesForAccount: Retrieve balances of specified tokens for a particular account + * getAllTokenBalancesForAccount: Retrieve balances of specified tokens for a particular account * getCurrencyValueForTokens: Calculate value of tokens in a specific currency * getTotalCurrencyBalanceForAccounts: Calculate total balance, given a list of accounts, and return as string */ getToken: (baseToken: BaseToken) => Promise getTokens: (selector?: SelectorFn) => Promise - getTokenBalancesForAccount: ( + getAllTokenBalancesForAccount: ( account: BaseWalletAccount, tokens: Token[], ) => Promise + getTokenBalanceForAccount: ( + account: BaseWalletAccount, + token: Token, + ) => Promise getCurrencyValueForTokens: ( tokensWithBalances: BaseTokenWithBalance[], ) => Promise getTotalCurrencyBalanceForAccounts: ( accounts: BaseWalletAccount[], ) => Promise<{ [key: string]: string }> + getTokenInfo: (token: BaseToken) => Promise } diff --git a/packages/extension/src/shared/token/__new/service/TokenService.test.ts b/packages/extension/src/shared/token/__new/service/TokenService.test.ts index 1cfc6dab7..3e4226d6a 100644 --- a/packages/extension/src/shared/token/__new/service/TokenService.test.ts +++ b/packages/extension/src/shared/token/__new/service/TokenService.test.ts @@ -1,11 +1,18 @@ import "fake-indexeddb/auto" -import { Mocked } from "vitest" +import type { Mocked } from "vitest" -import { NetworkService } from "../../../network/service/NetworkService" -import { INetworkService } from "../../../network/service/INetworkService" -import { MockFnRepository } from "../../../storage/__new/__test__/mockFunctionImplementation" -import { TokenService } from "./TokenService" +import type { IHttpService } from "@argent/x-shared" +import { + addressSchema, + ensureArray, + stripAddressZeroPadding, +} from "@argent/x-shared" +import { GatewayError, stark } from "starknet" +import { + getMockNetwork, + getMockNetworkWithoutMulticall, +} from "../../../../../test/network.mock" import { getMockApiTokenDetails, getMockBaseToken, @@ -13,25 +20,21 @@ import { getMockToken, getMockTokenPriceDetails, } from "../../../../../test/token.mock" -import { defaultNetwork } from "../../../network" -import { - getMockNetwork, - getMockNetworkWithoutMulticall, -} from "../../../../../test/network.mock" -import { INetworkRepo } from "../../../network/store" -import { GatewayError, shortString, stark } from "starknet" -import { - IHttpService, - addressSchema, - ensureArray, - stripAddressZeroPadding, -} from "@argent/x-shared" import { TokenError } from "../../../errors/token" import { ArgentDatabase } from "../../../idb/db" +import { defaultNetwork } from "../../../network" +import type { INetworkService } from "../../../network/service/INetworkService" +import { NetworkService } from "../../../network/service/NetworkService" +import type { INetworkRepo } from "../../../network/store" +import { MockFnRepository } from "../../../storage/__new/__test__/mockFunctionImplementation" import { equalToken } from "../utils" +import { TokenService } from "./TokenService" +import { getMockSigner } from "../../../../../test/account.mock" +import { getAccountIdentifier } from "../../../utils/accountIdentifier" const BASE_INFO_ENDPOINT = "https://token.info.argent47.net/v1" const BASE_PRICES_ENDPOINT = "https://token.prices.argent47.net/v1" +const BASE_TOKENS_REPORT_SPAM = "https://token.report-spam.argent47.net/v1" const randomAddress1 = addressSchema.parse(stark.randomAddress()) const randomAddress2 = addressSchema.parse(stark.randomAddress()) @@ -53,6 +56,7 @@ describe("TokenService", () => { mockHttpService = { get: vi.fn(), + post: vi.fn(), } as unknown as Mocked db = new ArgentDatabase() @@ -63,12 +67,13 @@ describe("TokenService", () => { mockHttpService, BASE_INFO_ENDPOINT, BASE_PRICES_ENDPOINT, + BASE_TOKENS_REPORT_SPAM, ) }) afterEach(() => { vi.clearAllMocks() - db.delete() + void db.delete() }) it("should add a token", async () => { @@ -115,6 +120,29 @@ describe("TokenService", () => { ]) }) + it("should toggle hidden flag for tokens", async () => { + const mockTokens = [ + getMockToken(), + getMockToken({ address: "0x234", hidden: true }), + getMockToken({ address: "0x345", hidden: true }), + ] + await tokenService.addToken(mockTokens) + + await tokenService.toggleHideToken(mockTokens[0], true) + await tokenService.toggleHideToken(mockTokens[1], false) + await tokenService.toggleHideToken(mockTokens[2], false) + + const tokens = await db.tokens + .filter((token) => mockTokens.some((t) => equalToken(token, t))) + .toArray() + + expect(tokens).toEqual([ + getMockToken({ hidden: true }), + getMockToken({ address: "0x234", hidden: false }), + getMockToken({ address: "0x345", hidden: false }), + ]) + }) + it("should update token balances", async () => { const mockTokensWithBalance = [ getMockBaseTokenWithBalance(), @@ -259,6 +287,12 @@ describe("TokenService", () => { const result = await tokenService.fetchAccountTokenBalancesFromBackend( { + id: getAccountIdentifier( + "0x123", + networkId, + getMockSigner(), + false, + ), address: "0x123", networkId, }, @@ -299,6 +333,12 @@ describe("TokenService", () => { const result = await tokenService.fetchAccountTokenBalancesFromBackend( { + id: getAccountIdentifier( + "0x123", + networkId, + getMockSigner(), + false, + ), address: "0x123", networkId, }, @@ -328,6 +368,12 @@ describe("TokenService", () => { describe("when not default network", () => { it("should return empty array", async () => { const result = await tokenService.fetchAccountTokenBalancesFromBackend({ + id: getAccountIdentifier( + "0x123", + "invalid-network", + getMockSigner(), + false, + ), address: "0x123", networkId: "invalid-network", }) @@ -351,6 +397,7 @@ describe("TokenService", () => { mockHttpService, BASE_INFO_ENDPOINT, BASE_PRICES_ENDPOINT, + BASE_TOKENS_REPORT_SPAM, ) const mockNetworkId = defaultNetwork.id @@ -388,7 +435,11 @@ describe("TokenService", () => { describe("fetch onchain token balances", () => { it("should fetch token balances from on-chain for same network", async () => { const mockNetwork = getMockNetwork() - const mockAccount = { address: randomAddress1, networkId: mockNetwork.id } + const mockAccount = { + address: randomAddress1, + networkId: mockNetwork.id, + id: "id", + } const mockBaseTokens = [ getMockBaseToken({ networkId: mockNetwork.id }), @@ -433,8 +484,8 @@ describe("TokenService", () => { const mockNetwork = getMockNetwork() const defaultMockNetwork = getMockNetwork({ id: defaultNetwork.id }) const mockAccounts = [ - { address: randomAddress1, networkId: mockNetwork.id }, - { address: randomAddress2, networkId: defaultNetwork.id }, + { address: randomAddress1, networkId: mockNetwork.id, id: "id" }, + { address: randomAddress2, networkId: defaultNetwork.id, id: "id" }, ] const mockBaseTokens = [ @@ -581,11 +632,7 @@ describe("TokenService", () => { .mockResolvedValueOnce(getMockNetwork()) tokenService.fetchTokenDetailsWithMulticall = vi .fn() - .mockResolvedValueOnce([ - shortString.encodeShortString(mockToken.name), - shortString.encodeShortString(mockToken.symbol), - mockToken.decimals, - ]) + .mockResolvedValueOnce(mockToken) const result = await tokenService.fetchTokenDetails(mockBaseToken) @@ -602,11 +649,7 @@ describe("TokenService", () => { .mockResolvedValueOnce(getMockNetworkWithoutMulticall()) tokenService.fetchTokenDetailsWithoutMulticall = vi .fn() - .mockResolvedValueOnce([ - shortString.encodeShortString(mockToken.name), - shortString.encodeShortString(mockToken.symbol), - mockToken.decimals, - ]) + .mockResolvedValueOnce(mockToken) const result = await tokenService.fetchTokenDetails(mockBaseToken) @@ -624,11 +667,10 @@ describe("TokenService", () => { .mockResolvedValueOnce(getMockNetworkWithoutMulticall()) tokenService.fetchTokenDetailsWithoutMulticall = vi .fn() - .mockResolvedValueOnce([ - shortString.encodeShortString(mockToken.name), - shortString.encodeShortString(mockToken.symbol), - Number.MAX_SAFE_INTEGER + 1, - ]) + .mockResolvedValueOnce({ + ...mockToken, + decimals: Number.MAX_SAFE_INTEGER + 1, + }) await expect( tokenService.fetchTokenDetails(mockBaseToken), ).rejects.toThrowError( @@ -692,7 +734,7 @@ describe("TokenService", () => { await db.tokenBalances.bulkPut(mockTokenBalances) - const result = await tokenService.getTokenBalancesForAccount( + const result = await tokenService.getAllTokenBalancesForAccount( mockAccount, mockTokens, ) @@ -816,8 +858,8 @@ describe("TokenService", () => { describe("get total currency balance for account", () => { it("should get total currency balance for different accounts", async () => { const mockAccounts = [ - { address: randomAddress1, networkId: defaultNetwork.id }, - { address: randomAddress2, networkId: defaultNetwork.id }, + { address: randomAddress1, networkId: defaultNetwork.id, id: "id" }, + { address: randomAddress2, networkId: defaultNetwork.id, id: "id" }, ] const mockBaseTokens = [ @@ -857,6 +899,7 @@ describe("TokenService", () => { const result = await tokenService.getTotalCurrencyBalanceForAccounts( mockAccounts.map((acc) => { return { + id: acc.id, address: stripAddressZeroPadding(acc.address), networkId: acc.networkId, } @@ -875,6 +918,7 @@ describe("TokenService", () => { const mockAccount = { address: stripAddressZeroPadding(randomAddress1), networkId: defaultNetwork.id, + id: "id", } const mockBaseTokens = [ getMockBaseToken({ networkId: defaultNetwork.id }), @@ -929,4 +973,149 @@ describe("TokenService", () => { [`${mockAccount.address}:${mockAccount.networkId}`]: "2200", }) }) + + describe("reportSpamToken", () => { + const mockToken = getMockBaseToken() + const mockAccount = { + address: "0x123", + networkId: "mainnet-alpha", + id: "id", + } + + it("should call http post and return void when successful", async () => { + mockHttpService.post.mockResolvedValueOnce({}) + + const result = await tokenService.reportSpamToken(mockToken, mockAccount) + + expect(mockHttpService.post).toHaveBeenCalledWith( + BASE_TOKENS_REPORT_SPAM, + { + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + tokenAddress: mockToken.address, + reporterAddress: mockAccount.address, + }), + }, + ) + expect(result).toBeUndefined() + }) + + it("should call http post and return void when address has already reported", async () => { + mockHttpService.post.mockResolvedValueOnce({ + status: "addressHasAlreadyReportedToken", + }) + + const result = await tokenService.reportSpamToken(mockToken, mockAccount) + + expect(mockHttpService.post).toHaveBeenCalledWith( + BASE_TOKENS_REPORT_SPAM, + { + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + tokenAddress: mockToken.address, + reporterAddress: mockAccount.address, + }), + }, + ) + expect(result).toBeUndefined() + }) + + it("should log error and return void when http post fails", async () => { + const consoleErrorSpy = vi + .spyOn(console, "error") + .mockImplementation(() => {}) + mockHttpService.post.mockRejectedValueOnce(new Error("HTTP error")) + + const result = await tokenService.reportSpamToken(mockToken, mockAccount) + + expect(mockHttpService.post).toHaveBeenCalledWith( + BASE_TOKENS_REPORT_SPAM, + { + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + tokenAddress: mockToken.address, + reporterAddress: mockAccount.address, + }), + }, + ) + expect(consoleErrorSpy).toHaveBeenCalledWith( + "Error while reporting spam token", + ) + expect(result).toBeUndefined() + + consoleErrorSpy.mockRestore() + }) + }) + describe("getTokenInfo", () => { + it("should return token info when it exists", async () => { + const mockToken = getMockToken() + const mockTokenInfo = { + ...mockToken, + id: 1, + iconUrl: "https://example.com/icon.png", + popular: true, + tradable: true, + sendable: true, + refundable: true, + listed: true, + category: "tokens" as const, + } + await db.tokensInfo.add(mockTokenInfo) + + const result = await tokenService.getTokenInfo(mockToken) + + expect(result).toEqual(mockTokenInfo) + }) + + it("should return undefined when token info doesn't exist", async () => { + const mockToken = getMockToken() + + const result = await tokenService.getTokenInfo(mockToken) + + expect(result).toBeUndefined() + }) + + it("should return the correct token info when multiple tokens exist", async () => { + const mockToken1 = getMockToken() + const mockToken2 = getMockToken({ + address: "0x456", + networkId: "network-2", + }) + const mockTokenInfo1 = { + ...mockToken1, + id: 1, + iconUrl: "https://example.com/icon1.png", + popular: true, + tradable: true, + sendable: true, + refundable: true, + listed: true, + category: "tokens" as const, + } + const mockTokenInfo2 = { + ...mockToken2, + id: 2, + iconUrl: "https://example.com/icon2.png", + popular: false, + tradable: false, + sendable: true, + refundable: true, + listed: true, + category: "tokens" as const, + } + await db.tokensInfo.bulkAdd([mockTokenInfo1, mockTokenInfo2]) + + const result1 = await tokenService.getTokenInfo(mockToken1) + const result2 = await tokenService.getTokenInfo(mockToken2) + + expect(result1).toEqual(mockTokenInfo1) + expect(result2).toEqual(mockTokenInfo2) + }) + }) }) diff --git a/packages/extension/src/shared/token/__new/service/TokenService.ts b/packages/extension/src/shared/token/__new/service/TokenService.ts index 99048fd97..e822972e3 100644 --- a/packages/extension/src/shared/token/__new/service/TokenService.ts +++ b/packages/extension/src/shared/token/__new/service/TokenService.ts @@ -1,42 +1,49 @@ -import { +import type { ApiAccountTokenBalances, + ApiTokenInfo, + ApiTokensInfoResponse, IHttpService, - apiAccountTokenBalancesSchema, +} from "@argent/x-shared" +import { + apiPriceDataResponseSchema, + apiTokensInfoResponseSchema, bigDecimal, convertTokenAmountToCurrencyValue, ensureArray, isEqualAddress, stripAddressZeroPadding, - ApiTokenInfo, - ApiTokensInfoResponse, - apiTokensInfoResponseSchema, - apiPriceDataResponseSchema, + retryUntilInitialised, + apiAccountTokenBalancesSchema, } from "@argent/x-shared" -import retry from "async-retry" import { groupBy, isEmpty, uniq } from "lodash-es" -import { AllowArray, shortString, uint256 } from "starknet" +import type { AllowArray } from "starknet" +import { shortString, uint256 } from "starknet" import urlJoin from "url-join" import { ARGENT_API_BASE_URL } from "../../../api/constants" import { argentApiNetworkForNetwork } from "../../../api/headers" import { RefreshIntervalInSeconds } from "../../../config" import { TokenError } from "../../../errors/token" -import { ArgentDatabase } from "../../../idb/db" +import type { ArgentDatabase } from "../../../idb/db" +import { chunkedBulkPut } from "../../../idb/utils/chunkedBulkPut" import { getMulticallForNetwork } from "../../../multicall" -import { Network, defaultNetwork } from "../../../network" +import type { Network } from "../../../network" +import { defaultNetwork } from "../../../network" import { getProvider } from "../../../network/provider" -import { INetworkService } from "../../../network/service/INetworkService" +import type { INetworkService } from "../../../network/service/INetworkService" import { getDefaultNetworkId } from "../../../network/utils" -import { SelectorFn } from "../../../storage/__new/interface" -import { BaseWalletAccount } from "../../../wallet.model" -import { BaseToken, BaseTokenSchema, Token } from "../types/token.model" -import { BaseTokenWithBalance } from "../types/tokenBalance.model" -import { +import type { SelectorFn } from "../../../storage/__new/interface" +import type { BaseWalletAccount } from "../../../wallet.model" +import type { BaseToken, Token } from "../types/token.model" +import { BaseTokenSchema } from "../types/token.model" +import type { BaseTokenWithBalance } from "../types/tokenBalance.model" +import type { TokenPriceDetails, TokenWithBalanceAndPrice, } from "../types/tokenPrice.model" import { equalToken } from "../utils" -import { ITokenService } from "./ITokenService" -import { chunkedBulkPut } from "../../../idb/chunkedBulkPut" +import type { ITokenService, FetchedTokenDetails } from "./ITokenService" +import type retry from "async-retry" +import { decodeShortStringArray } from "../utils/decodeShortStringArray" /** * TokenService class implements ITokenService interface. @@ -45,12 +52,15 @@ import { chunkedBulkPut } from "../../../idb/chunkedBulkPut" export class TokenService implements ITokenService { private readonly TOKENS_INFO_URL: string private readonly TOKENS_PRICES_URL: string + private readonly ARGENT_API_TOKENS_REPORT_SPAM_URL: string + constructor( private readonly networkService: INetworkService, private readonly db: ArgentDatabase, private readonly httpService: IHttpService, TOKENS_INFO_URL: string | undefined, TOKENS_PRICES_URL: string | undefined, + ARGENT_API_TOKENS_REPORT_SPAM_URL: string | undefined, ) { if (!TOKENS_INFO_URL) { throw new TokenError({ code: "NO_TOKEN_API_URL" }) @@ -58,8 +68,12 @@ export class TokenService implements ITokenService { if (!TOKENS_PRICES_URL) { throw new TokenError({ code: "NO_TOKEN_PRICE_API_URL" }) } + if (!ARGENT_API_TOKENS_REPORT_SPAM_URL) { + throw new TokenError({ code: "NO_TOKEN_REPORT_SPAM_API_URL" }) + } this.TOKENS_INFO_URL = TOKENS_INFO_URL this.TOKENS_PRICES_URL = TOKENS_PRICES_URL + this.ARGENT_API_TOKENS_REPORT_SPAM_URL = ARGENT_API_TOKENS_REPORT_SPAM_URL } /** @@ -86,6 +100,20 @@ export class TokenService implements ITokenService { await this.db.tokens.filter((t) => equalToken(t, baseToken)).delete() } + /** + * Hide a token in the token repository. + * @param {BaseToken} baseToken - The base token to remove. + */ + async toggleHideToken(baseToken: BaseToken, hidden: boolean): Promise { + const allTokens = await this.db.tokens.toArray() + const token = allTokens.find((t) => equalToken(t, baseToken)) + if (!token) { + return + } + token.hidden = hidden + await this.updateTokens(token) + } + /** * Update token balances in the token balance repository. * @param {AllowArray} tokensWithBalance - The tokens with balance to update. @@ -357,17 +385,15 @@ export class TokenService implements ITokenService { } const network = await this.networkService.getById(baseToken.networkId) - let name: string, symbol: string, decimals: string + let name: string, symbol: string, decimals: number try { if (network.multicallAddress) { - ;[name, symbol, decimals] = await this.fetchTokenDetailsWithMulticall( - baseToken, - network, - ) + ;({ name, symbol, decimals } = + await this.fetchTokenDetailsWithMulticall(baseToken, network)) } else { - ;[name, symbol, decimals] = - await this.fetchTokenDetailsWithoutMulticall(baseToken, network) + ;({ name, symbol, decimals } = + await this.fetchTokenDetailsWithoutMulticall(baseToken, network)) } } catch (error) { console.error(error) @@ -377,28 +403,41 @@ export class TokenService implements ITokenService { }) } - if (Number.parseInt(decimals) > Number.MAX_SAFE_INTEGER) { + if (decimals > Number.MAX_SAFE_INTEGER) { throw new TokenError({ code: "UNSAFE_DECIMALS", options: { context: { decimals } }, }) } - return { + console.log("decimals", [name, symbol, decimals]) + + const fetchedToken = { address: baseToken.address, networkId: baseToken.networkId, - name: shortString.decodeShortString(name), - symbol: shortString.decodeShortString(symbol), - decimals: Number.parseInt(decimals), + name, + symbol, + decimals, custom: true, } + + return fetchedToken + } + + decodeFetchedTokenDetailsResponse(response: string[][]): FetchedTokenDetails { + const res = { + name: decodeShortStringArray(response[0]), + symbol: decodeShortStringArray(response[1]), + decimals: Number.parseInt(response[2][0]), + } + return res } async fetchTokenDetailsWithMulticall( baseToken: BaseToken, network: Network, tokenEntryPoints = ["name", "symbol", "decimals"], - ): Promise { + ): Promise { const multicall = getMulticallForNetwork(network) const responses = await Promise.all( tokenEntryPoints.map((entrypoint) => @@ -408,14 +447,14 @@ export class TokenService implements ITokenService { }), ), ) - return responses.map((response) => response[0]) + return this.decodeFetchedTokenDetailsResponse(responses) } async fetchTokenDetailsWithoutMulticall( baseToken: BaseToken, network: Network, tokenEntryPoints = ["name", "symbol", "decimals"], - ): Promise { + ): Promise { const provider = getProvider(network) const responses = await Promise.all( tokenEntryPoints.map((entrypoint) => @@ -425,7 +464,7 @@ export class TokenService implements ITokenService { }), ), ) - return responses.map((response) => response[0]) + return this.decodeFetchedTokenDetailsResponse(responses) } async getToken(baseToken: BaseToken): Promise { @@ -455,8 +494,8 @@ export class TokenService implements ITokenService { * @param {Token[]} tokens - The tokens. * @returns {Promise} - The fetched token balances. */ - async getTokenBalancesForAccount( - account: BaseWalletAccount, + async getAllTokenBalancesForAccount( + account: Omit, tokens: Token[], ): Promise { const allTokenBalances = await this.db.tokenBalances.toArray() @@ -470,6 +509,19 @@ export class TokenService implements ITokenService { return tokenBalances } + async getTokenBalanceForAccount( + account: Omit, // token balances are unique by address and network + token: Token, + ): Promise { + const allTokenBalances = await this.db.tokenBalances.toArray() + const tokenBalance = allTokenBalances.find( + (t) => + t.networkId === account.networkId && + isEqualAddress(t.account, account.address) && + equalToken(t, token), + ) + return tokenBalance + } /** * Get currency value for tokens. * @param {BaseTokenWithBalance[]} tokensWithBalances - The tokens with balances. @@ -602,34 +654,17 @@ export class TokenService implements ITokenService { ) /** retry until status is "initialised" */ - const accountTokenBalances = await retry( - async (bail) => { - let response - try { - response = await this.httpService.get(url) - } catch (e) { - /** bail without retry if there is any fetching error */ - bail(new Error("Error fetching")) - return [] - } - const parsedRespose = apiAccountTokenBalancesSchema.safeParse(response) - if (!parsedRespose.success) { - bail(new Error("Error parsing response")) - return [] - } - if (parsedRespose.data.status !== "initialised") { - /** causes a retry */ - throw new Error("Not initialised yet") - } - return parsedRespose.data.balances - }, - { - /** seems to take 5-10 sec for initialised state */ - retries: 5, - minTimeout: 5000, - ...opts, - }, - ) + const accountTokenBalancesResult = + await retryUntilInitialised( + () => this.httpService.get(url), + apiAccountTokenBalancesSchema, + opts, + ) + + const accountTokenBalances = + accountTokenBalancesResult?.status === "initialised" + ? accountTokenBalancesResult.balances + : [] const baseTokenWithBalances: BaseTokenWithBalance[] = accountTokenBalances.map((accountTokenBalance) => { @@ -643,4 +678,28 @@ export class TokenService implements ITokenService { return baseTokenWithBalances } + + async reportSpamToken( + token: BaseToken, + account: BaseWalletAccount, + ): Promise { + try { + await this.httpService.post(this.ARGENT_API_TOKENS_REPORT_SPAM_URL, { + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + tokenAddress: token.address, + reporterAddress: account.address, + }), + }) + } catch { + console.error("Error while reporting spam token") + } + } + + async getTokenInfo(token: BaseToken): Promise { + const allTokensInfo = await this.db.tokensInfo.toArray() + return allTokensInfo.find((t) => equalToken(t, token)) + } } diff --git a/packages/extension/src/shared/token/__new/service/index.ts b/packages/extension/src/shared/token/__new/service/index.ts index 8204385db..273a04aff 100644 --- a/packages/extension/src/shared/token/__new/service/index.ts +++ b/packages/extension/src/shared/token/__new/service/index.ts @@ -1,11 +1,12 @@ import { ARGENT_API_TOKENS_INFO_URL, ARGENT_API_TOKENS_PRICES_URL, + ARGENT_API_TOKENS_REPORT_SPAM_URL, } from "../../../api/constants" import { httpService } from "../../../http/singleton" import { networkService } from "../../../network/service" import { TokenService } from "./TokenService" -import { argentDb } from "../../../idb/db" +import { argentDb } from "../../../idb/argentDb" export const tokenService = new TokenService( networkService, @@ -13,4 +14,5 @@ export const tokenService = new TokenService( httpService, ARGENT_API_TOKENS_INFO_URL, ARGENT_API_TOKENS_PRICES_URL, + ARGENT_API_TOKENS_REPORT_SPAM_URL, ) diff --git a/packages/extension/src/shared/token/__new/types/token.model.ts b/packages/extension/src/shared/token/__new/types/token.model.ts index fb4fca7f7..bce1f4889 100644 --- a/packages/extension/src/shared/token/__new/types/token.model.ts +++ b/packages/extension/src/shared/token/__new/types/token.model.ts @@ -29,7 +29,9 @@ export const TokenSchema = RequestTokenSchema.required().extend({ custom: z.boolean().optional(), pricingId: z.number().optional(), tradable: z.boolean().optional(), + hidden: z.boolean().optional(), tags: z.string().array().optional(), + brandColor: z.string().optional(), }) export type Token = z.infer diff --git a/packages/extension/src/shared/token/__new/types/tokenInfo.model.ts b/packages/extension/src/shared/token/__new/types/tokenInfo.model.ts index 42755d665..b325a848b 100644 --- a/packages/extension/src/shared/token/__new/types/tokenInfo.model.ts +++ b/packages/extension/src/shared/token/__new/types/tokenInfo.model.ts @@ -1,4 +1,4 @@ -import { addressSchema, apiTokenInfoSchema } from "@argent/x-shared" +import { apiTokenInfoSchema } from "@argent/x-shared" import { z } from "zod" export const dbTokenInfoSchema = apiTokenInfoSchema.extend({ diff --git a/packages/extension/src/shared/token/__new/utils/decodeShortStringArray.test.ts b/packages/extension/src/shared/token/__new/utils/decodeShortStringArray.test.ts new file mode 100644 index 000000000..9fa272a88 --- /dev/null +++ b/packages/extension/src/shared/token/__new/utils/decodeShortStringArray.test.ts @@ -0,0 +1,28 @@ +import { describe, it, expect } from "vitest" +import { decodeShortStringArray } from "./decodeShortStringArray" + +describe("decodeShortStringArray", () => { + it("should correctly decode an array of short strings", () => { + const input = ["0x0", "0x4650485f446f67655f5334", "0xb"] + const result = decodeShortStringArray(input) + expect(result).toBe("FPH_Doge_S4") + }) + + it("should decode another array of short strings", () => { + const input = ["0x0", "0x53344447", "0x4"] + const result = decodeShortStringArray(input) + expect(result).toBe("S4DG") + }) + + it("should decode a single short string", () => { + const input = ["0x4574686572"] + const result = decodeShortStringArray(input) + expect(result).toBe("Ether") + }) + + it("should decode ETH short string", () => { + const input = ["0x455448"] + const result = decodeShortStringArray(input) + expect(result).toBe("ETH") + }) +}) diff --git a/packages/extension/src/shared/token/__new/utils/decodeShortStringArray.ts b/packages/extension/src/shared/token/__new/utils/decodeShortStringArray.ts new file mode 100644 index 000000000..944eb6abd --- /dev/null +++ b/packages/extension/src/shared/token/__new/utils/decodeShortStringArray.ts @@ -0,0 +1,8 @@ +import { shortString } from "starknet" + +export function decodeShortStringArray(shortStringArray: string[]): string { + if (shortStringArray.length === 3) { + return shortString.decodeShortString(shortStringArray[1]) + } + return shortString.decodeShortString(shortStringArray[0]) +} diff --git a/packages/extension/src/shared/token/__new/utils/index.ts b/packages/extension/src/shared/token/__new/utils/index.ts index bd0e7aebc..0d0e112e5 100644 --- a/packages/extension/src/shared/token/__new/utils/index.ts +++ b/packages/extension/src/shared/token/__new/utils/index.ts @@ -1,7 +1,7 @@ import { addressSchema, isEqualAddress } from "@argent/x-shared" -import { BaseToken, Token } from "../types/token.model" import defaultTokens from "../../../../assets/default-tokens.json" +import type { BaseToken, Token } from "../types/token.model" export const equalToken = (a?: BaseToken, b?: BaseToken) => { if (!a || !b) { @@ -9,6 +9,12 @@ export const equalToken = (a?: BaseToken, b?: BaseToken) => { } return a.networkId === b.networkId && isEqualAddress(a.address, b.address) } +export const atomFamilyTokenEqual = (a?: BaseToken, b?: BaseToken) => { + if (!a && !b) { + return true + } + return equalToken(a, b) +} export const parsedDefaultTokens: Token[] = defaultTokens.map((token) => ({ ...token, diff --git a/packages/extension/src/shared/token/prettifyTokenBalance.ts b/packages/extension/src/shared/token/prettifyTokenBalance.ts index f6d12be73..fca01fb36 100644 --- a/packages/extension/src/shared/token/prettifyTokenBalance.ts +++ b/packages/extension/src/shared/token/prettifyTokenBalance.ts @@ -1,5 +1,6 @@ +import type { IPrettifyNumberConfig } from "@argent/x-shared" import { prettifyTokenAmount } from "@argent/x-shared" -import { TokenWithOptionalBigIntBalance } from "./__new/types/tokenBalance.model" +import type { TokenWithOptionalBigIntBalance } from "./__new/types/tokenBalance.model" /** * Returns a string of token balance with symbol if available e.g. @@ -8,6 +9,7 @@ import { TokenWithOptionalBigIntBalance } from "./__new/types/tokenBalance.model export const prettifyTokenBalance = ( token: TokenWithOptionalBigIntBalance, withSymbol = true, + overrides?: Partial, ) => { const { balance, decimals, symbol } = token if (balance === undefined || decimals === undefined) { @@ -17,5 +19,6 @@ export const prettifyTokenBalance = ( amount: balance, decimals, symbol: withSymbol ? symbol : "", + prettyConfigOverrides: overrides, }) } diff --git a/packages/extension/src/shared/tokenDetails/interface.ts b/packages/extension/src/shared/tokenDetails/interface.ts new file mode 100644 index 000000000..f44ed60e2 --- /dev/null +++ b/packages/extension/src/shared/tokenDetails/interface.ts @@ -0,0 +1,38 @@ +import type { Address } from "@argent/x-shared" +import { z } from "zod" + +export const apiTokenGraphDataSchema = z.object({ + info: z.object({ + currency: z.string(), + timeframe: z.string(), + timeIntervals: z.number(), + }), + prices: z.array( + z.object({ + date: z.number(), + ethValue: z.number().optional(), + ccyValue: z.number().optional(), + }), + ), +}) + +export type TokenGraphInput = { + tokenAddress: Address + currency: string + timeFrame: string + chain: string +} +export type TokenGraphDataApi = z.infer + +/** + * ITokenService interface provides methods for managing tokens, including storage methods, fetch methods, and get methods. + */ +export interface ITokensDetailsService { + /** + * Fetch methods - These methods fetch data from backend or chain + * fetchTokenGraph: Fetch graph data for a token + */ + fetchTokenGraph: ( + input: TokenGraphInput, + ) => Promise +} diff --git a/packages/extension/src/shared/transactionReview.service.test.ts b/packages/extension/src/shared/transactionReview.service.test.ts index 39ad27cbd..3122a9e0a 100644 --- a/packages/extension/src/shared/transactionReview.service.test.ts +++ b/packages/extension/src/shared/transactionReview.service.test.ts @@ -1,44 +1,153 @@ +/* eslint-disable @typescript-eslint/ban-ts-comment */ import send from "./transactionReview/__fixtures__/send.json" import swap from "./transactionReview/__fixtures__/swap.json" +import sendNft from "./transactionReview/__fixtures__/send-nft.json" import { + getReviewOfTransaction, + getTransactionActionByType, + getTransactionReviewPropertyByType, + getTransactionReviewSwapToken, + transactionReviewHasNft, transactionReviewHasSwap, transactionReviewHasTransfer, } from "./transactionReview.service" describe("transactionReviewService", () => { - it("should return transaction review is transfer", () => { - const result = transactionReviewHasTransfer( - // eslint-disable-next-line @typescript-eslint/ban-ts-comment + describe("getReviewOfTransaction", () => { + it("should return review of transaction", () => { // @ts-ignore - send.transactions[0].reviewOfTransaction, - ) - expect(result).toBe(true) - }) + const result = getReviewOfTransaction(send) + expect(result).toBeDefined() + }) - it("should return transaction review is not transfer", () => { - const result = transactionReviewHasTransfer( - // eslint-disable-next-line @typescript-eslint/ban-ts-comment + it("should return undefined for bad input", () => { + expect(getReviewOfTransaction(undefined)).toBeUndefined() + // @ts-ignore + expect(getReviewOfTransaction({})).toBeUndefined() + expect(getReviewOfTransaction({ transactions: [] })).toBeUndefined() // @ts-ignore - swap.transactions[0].reviewOfTransaction, - ) - expect(result).toBe(false) + expect(getReviewOfTransaction({ transactions: [{}] })).toBeUndefined() + }) }) - it("should return transaction review is swap", () => { - const result = transactionReviewHasSwap( - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - swap.transactions[0].reviewOfTransaction, - ) - expect(result).toBe(true) + describe("transactionReviewHasTransfer", () => { + it("should return transaction review is transfer", () => { + const result = transactionReviewHasTransfer( + // @ts-ignore + getReviewOfTransaction(send), + ) + expect(result).toBe(true) + }) + + it("should return transaction review is not transfer", () => { + const result = transactionReviewHasTransfer( + // @ts-ignore + getReviewOfTransaction(swap), + ) + expect(result).toBe(false) + }) + }) + + describe("transactionReviewHasSwap", () => { + it("should return transaction review is swap", () => { + const result = transactionReviewHasSwap( + // @ts-ignore + getReviewOfTransaction(swap), + ) + expect(result).toBe(true) + }) + + it("should return transaction review is not swap", () => { + const result = transactionReviewHasSwap( + // @ts-ignore + getReviewOfTransaction(send), + ) + expect(result).toBe(false) + }) + }) + + describe("transactionReviewHasNft", () => { + it("should return transaction review has nft", () => { + const result = transactionReviewHasNft( + // @ts-ignore + getReviewOfTransaction(sendNft), + ) + expect(result).toBe(true) + }) + + it("should return transaction review does not have nft", () => { + const result = transactionReviewHasNft( + // @ts-ignore + getReviewOfTransaction(send), + ) + expect(result).toBe(false) + }) + }) + + describe("getTransactionActionByType", () => { + it("should return action by type", () => { + const action = getTransactionActionByType( + "ERC20_transfer", + // @ts-ignore + getReviewOfTransaction(send), + ) + expect(action).toBeDefined() + expect(action?.name).toBe("ERC20_transfer") + }) + + it("should return undefined for action with incorrect type", () => { + const action = getTransactionActionByType( + "test", + // @ts-ignore + getReviewOfTransaction(send), + ) + expect(action).toBeUndefined() + }) + }) + + describe("getTransactionReviewPropertyByType", () => { + it("should return review property by type", () => { + const property = getTransactionReviewPropertyByType( + "token_address", + // @ts-ignore + getReviewOfTransaction(send), + ) + expect(property).toBeDefined() + expect(property?.type).toBe("token_address") + }) + + it("should return undefined for property with incorrect type", () => { + const property = getTransactionReviewPropertyByType( + "test", + // @ts-ignore + getReviewOfTransaction(send), + ) + expect(property).toBeUndefined() + }) }) - it("should return transaction review is not swap", () => { - const result = transactionReviewHasSwap( - // eslint-disable-next-line @typescript-eslint/ban-ts-comment + describe("getTransactionReviewSwapToken", () => { + it("should return swap token by type", () => { + // @ts-ignore + const srcToken = getTransactionReviewSwapToken(swap, true) // @ts-ignore - send.transactions[0].reviewOfTransaction, - ) - expect(result).toBe(false) + const dstToken = getTransactionReviewSwapToken(swap, false) + + expect(srcToken).toBeDefined() + expect(dstToken).toBeDefined() + expect(srcToken?.symbol).toBe("ETH") + expect(dstToken?.symbol).toBe("DAI") + }) + + it("should not return destination token for transfer", () => { + // @ts-ignore + const srcToken = getTransactionReviewSwapToken(send, true) + // @ts-ignore + const dstToken = getTransactionReviewSwapToken(send, false) + + expect(srcToken).toBeDefined() + expect(dstToken).toBeUndefined() + expect(srcToken?.symbol).toBe("ETH") + }) }) }) diff --git a/packages/extension/src/shared/transactionReview.service.ts b/packages/extension/src/shared/transactionReview.service.ts index 5d68bbd15..5ba143d65 100644 --- a/packages/extension/src/shared/transactionReview.service.ts +++ b/packages/extension/src/shared/transactionReview.service.ts @@ -1,4 +1,10 @@ -import { ReviewOfTransaction, Action } from "@argent/x-shared/simulation" +import { isArray } from "lodash-es" +import type { + Action, + EnrichedSimulateAndReview, + Property, + ReviewOfTransaction, +} from "@argent/x-shared/simulation" export type ApiTransactionReviewTargettedDapp = { name: string @@ -14,7 +20,7 @@ export type ApiTransactionReviewTargettedDapp = { export const transactionReviewHasSwap = ( transactionReview?: ReviewOfTransaction, ) => { - if (!transactionReview) { + if (!isArray(transactionReview?.reviews)) { return false } for (const review of transactionReview.reviews) { @@ -29,12 +35,26 @@ export const transactionReviewHasSwap = ( return false } -export const transactionReviewHasTransfer = ( +export const transactionReviewHasNft = ( transactionReview?: ReviewOfTransaction, ) => { if (!transactionReview) { return false } + for (const review of transactionReview.reviews) { + if (review.action.name.includes("ERC721")) { + return true + } + } + return false +} + +export const transactionReviewHasTransfer = ( + transactionReview?: ReviewOfTransaction, +) => { + if (!isArray(transactionReview?.reviews)) { + return false + } for (const review of transactionReview.reviews) { if (review.action.name === "ERC20_transfer") { return true @@ -47,7 +67,7 @@ export const getTransactionActionByType = ( actionName?: string, transactionReview?: ReviewOfTransaction, ): Action | undefined => { - if (!transactionReview || !actionName) { + if (!isArray(transactionReview?.reviews) || !actionName) { return } for (const review of transactionReview.reviews) { @@ -56,3 +76,39 @@ export const getTransactionActionByType = ( } } } + +export const getTransactionReviewPropertyByType = ( + propertyType?: string, + transactionReview?: ReviewOfTransaction, +): Property | undefined => { + if (!transactionReview || !propertyType) { + return + } + for (const review of transactionReview.reviews) { + for (const defaultProperty of review.action.defaultProperties || []) { + if (defaultProperty.type === propertyType) { + return defaultProperty + } + } + for (const property of review.action.properties) { + if (property.type === propertyType) { + return property + } + } + } +} + +export const getTransactionReviewSwapToken = ( + transactionReview?: EnrichedSimulateAndReview, + isSource?: boolean, +) => { + return transactionReview?.transactions?.[0]?.simulation?.summary?.find( + (p) => p.sent === isSource, + )?.token +} + +export const getReviewOfTransaction = ( + transactionReview?: EnrichedSimulateAndReview, +) => { + return transactionReview?.transactions?.[0]?.reviewOfTransaction +} diff --git a/packages/extension/src/shared/transactionReview/interface.ts b/packages/extension/src/shared/transactionReview/interface.ts index 87e1ba272..2d27e1afd 100644 --- a/packages/extension/src/shared/transactionReview/interface.ts +++ b/packages/extension/src/shared/transactionReview/interface.ts @@ -1,54 +1,33 @@ -import { z } from "zod" - -import { +import type { EnrichedSimulateAndReview, EstimatedFees, } from "@argent/x-shared/simulation" -import { +import type { Address, - Hex, ITransactionReviewBase, ITransactionReviewLabel, ITransactionReviewWarning, - callSchema, - hexSchema, + TransactionAction, } from "@argent/x-shared" -import { BaseWalletAccount } from "../wallet.model" -import { BigNumberish, Call } from "starknet" - -export const transactionReviewTransactionsSchema = z.object({ - type: z - .enum(["DECLARE", "DEPLOY", "DEPLOY_ACCOUNT", "INVOKE"]) - .default("INVOKE"), - calls: z.array(callSchema).or(callSchema).optional(), - calldata: z.array(z.string()).optional(), - classHash: hexSchema.optional(), - salt: hexSchema.optional(), - signature: z.array(z.string()).optional(), -}) - -export type TransactionReviewTransactions = z.infer< - typeof transactionReviewTransactionsSchema -> +import type { BaseWalletAccount } from "../wallet.model" +import type { BigNumberish, Call } from "starknet" +import type { AccountDeployTransaction } from "./transactionAction.model" export interface ITransactionReviewService extends ITransactionReviewBase { simulateAndReview({ - transactions, + transaction, + accountDeployTransaction, feeTokenAddress, appDomain, + maxSendEstimate, }: { - transactions: TransactionReviewTransactions[] + transaction: TransactionAction feeTokenAddress: Address + accountDeployTransaction?: AccountDeployTransaction appDomain?: string + maxSendEstimate?: boolean }): Promise - getTransactionHash( - baseAccount: BaseWalletAccount, - calls: Call | Call[], - estimatedFee?: EstimatedFees, - providedNonce?: BigNumberish, - ): Promise - getCompressedTransactionPayload( baseAccount: BaseWalletAccount, calls: Call | Call[], diff --git a/packages/extension/src/shared/transactionReview/store.ts b/packages/extension/src/shared/transactionReview/store.ts index d2a149ec6..5e21abd04 100644 --- a/packages/extension/src/shared/transactionReview/store.ts +++ b/packages/extension/src/shared/transactionReview/store.ts @@ -1,5 +1,5 @@ import { KeyValueStorage } from "../storage" -import { +import type { ITransactionReviewLabelsStore, ITransactionReviewWarningsStore, } from "./interface" diff --git a/packages/extension/src/shared/transactionReview/transactionAction.model.ts b/packages/extension/src/shared/transactionReview/transactionAction.model.ts new file mode 100644 index 000000000..b1cb74868 --- /dev/null +++ b/packages/extension/src/shared/transactionReview/transactionAction.model.ts @@ -0,0 +1,64 @@ +import { bigNumberishSchema, callSchema } from "@argent/x-shared" +import type { CompiledContract } from "starknet" +import { TransactionType } from "starknet" +import { z } from "zod" + +const declareContractPayloadSchema = z.object({ + contract: z + .union([z.string(), z.any().transform((val) => val as CompiledContract)]) + .refine((val) => val !== undefined, "contract is required"), // Leave it to starknet.js to validate + classHash: z.string().optional(), + casm: z.any().optional(), + compiledClassHash: z.string().optional(), +}) + +const deployAccountContractPayloadSchema = z.object({ + classHash: z.string(), + constructorCalldata: z.any().optional(), + addressSalt: bigNumberishSchema.optional(), + contractAddress: z.string().optional(), +}) + +const universalDeployerContractPayloadSchema = z.object({ + classHash: bigNumberishSchema, + salt: z.string().optional(), + unique: z.boolean().optional(), + constructorCalldata: z.any().optional(), // TODO: Parse RawArgs +}) + +export const accountDeployTransactionSchema = z.object({ + type: z.literal(TransactionType.DEPLOY_ACCOUNT), + payload: deployAccountContractPayloadSchema, +}) + +export type AccountDeployTransaction = z.infer< + typeof accountDeployTransactionSchema +> + +export const invokeTransactionSchema = z.object({ + type: z.literal(TransactionType.INVOKE), + payload: z.array(callSchema).or(callSchema), +}) + +export type InvokeTransaction = z.infer + +export const declareTransactionSchema = z.object({ + type: z.literal(TransactionType.DECLARE), + payload: declareContractPayloadSchema, +}) + +export type DeclareTransaction = z.infer + +export const deployTransactionSchema = z.object({ + type: z.literal(TransactionType.DEPLOY), + payload: universalDeployerContractPayloadSchema, +}) + +export type DeployTransaction = z.infer + +export const transactionActionSchema = z.union([ + accountDeployTransactionSchema, + invokeTransactionSchema, + declareTransactionSchema, + deployTransactionSchema, +]) diff --git a/packages/extension/src/shared/transactionSimulation/fees/estimatedFeesRepository.ts b/packages/extension/src/shared/transactionSimulation/fees/estimatedFeesRepository.ts index 372d88d95..82fab4d4f 100644 --- a/packages/extension/src/shared/transactionSimulation/fees/estimatedFeesRepository.ts +++ b/packages/extension/src/shared/transactionSimulation/fees/estimatedFeesRepository.ts @@ -1,16 +1,17 @@ import browser from "webextension-polyfill" -import { TransactionAction, ensureArray } from "@argent/x-shared" +import type { TransactionAction } from "@argent/x-shared" +import { ensureArray } from "@argent/x-shared" import { deserialize, serialize } from "superjson" import { TransactionType } from "starknet" import { ChromeRepository } from "../../storage/__new/chrome" -import { +import type { EstimatedFees, EstimatedFeesEnriched, } from "@argent/x-shared/simulation" import { objectHash } from "../../objectHash" import { assertNever } from "../../utils/assertNever" -import { IEstimatedFeesRepository } from "./fees.model" +import type { IEstimatedFeesRepository } from "./fees.model" export const estimatedFeesRepo: IEstimatedFeesRepository = new ChromeRepository(browser, { diff --git a/packages/extension/src/shared/transactionSimulation/fees/fees.model.ts b/packages/extension/src/shared/transactionSimulation/fees/fees.model.ts index 19749160f..dd4923470 100644 --- a/packages/extension/src/shared/transactionSimulation/fees/fees.model.ts +++ b/packages/extension/src/shared/transactionSimulation/fees/fees.model.ts @@ -1,4 +1,4 @@ -import { ChromeRepository } from "../../storage/__new/chrome" -import { EstimatedFeesEnriched } from "@argent/x-shared/simulation" +import type { ChromeRepository } from "../../storage/__new/chrome" +import type { EstimatedFeesEnriched } from "@argent/x-shared/simulation" export type IEstimatedFeesRepository = ChromeRepository diff --git a/packages/extension/src/shared/transactionSimulation/findTransferAndApproval.ts b/packages/extension/src/shared/transactionSimulation/findTransferAndApproval.ts index 6a6b7d410..bee0e5c07 100644 --- a/packages/extension/src/shared/transactionSimulation/findTransferAndApproval.ts +++ b/packages/extension/src/shared/transactionSimulation/findTransferAndApproval.ts @@ -1,7 +1,7 @@ import { hash, uint256 } from "starknet" -import { FunctionInvocation } from "starknet5" +import type { FunctionInvocation } from "starknet5" -import { ApprovalEvent, EventsToTrack, TransferEvent } from "./types" +import type { ApprovalEvent, EventsToTrack, TransferEvent } from "./types" export const EventsBySelector: { [key in EventsToTrack]: string } = { Transfer: hash.getSelectorFromName("Transfer"), // 0x99cd8bde557814842a3121e8ddfd433a539b8c9f14bf31ebf108d12e6196e9 diff --git a/packages/extension/src/shared/transactionSimulation/transactionSimulation.service.ts b/packages/extension/src/shared/transactionSimulation/transactionSimulation.service.ts deleted file mode 100644 index d2db4301c..000000000 --- a/packages/extension/src/shared/transactionSimulation/transactionSimulation.service.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { isArgentNetworkId } from "@argent/x-shared" -import { ARGENT_TRANSACTION_BULK_SIMULATION_URL } from "../api/constants" -import { fetcher } from "../api/fetcher" -import { TransactionError } from "../errors/transaction" -import { - ApiTransactionSimulationResponseUnparsed, - IFetchTransactionSimulationBulk, - ApiTransactionBulkSimulationResponse, -} from "./types" -import { argentXHeaders } from "../api/headers" - -export const fetchTransactionBulkSimulation = async ({ - invocations, - networkId, - chainId, - fetcher: fetcherImpl = fetcher, -}: IFetchTransactionSimulationBulk): Promise< - ApiTransactionBulkSimulationResponse | undefined -> => { - if (!ARGENT_TRANSACTION_BULK_SIMULATION_URL) { - throw new TransactionError({ - code: "SIMULATION_DISABLED", - }) - } - if (!isArgentNetworkId(networkId)) { - return - } - try { - const backendSimulation = - await fetcherImpl( - ARGENT_TRANSACTION_BULK_SIMULATION_URL, - { - method: "POST", - headers: { - Accept: "application/json", - "Content-Type": "application/json", - ...argentXHeaders, - }, - body: JSON.stringify({ - chainId, - transactions: invocations, - }), - }, - ) - - return backendSimulation.simulationResults - } catch (e) { - /** Disable client-side simulation - */ - // if ((e as SimulationError).status >= 500) { - // console.error("Failed to fetch transaction simulation from backend", e) - // console.warn("Falling back to client-side simulation") - // return undefined - // } - // throw e - return undefined - } -} diff --git a/packages/extension/src/shared/transactionSimulation/types.ts b/packages/extension/src/shared/transactionSimulation/types.ts index 7d8156ee1..c3ee060c9 100644 --- a/packages/extension/src/shared/transactionSimulation/types.ts +++ b/packages/extension/src/shared/transactionSimulation/types.ts @@ -1,14 +1,13 @@ -import { +import type { ArraySignatureType, Calldata, TransactionType, constants, } from "starknet" -import { Sequencer } from "starknet5" +import type { Sequencer } from "starknet5" -import { Fetcher } from "../api/fetcher" -import { EstimatedFees } from "@argent/x-shared/simulation" +import type { Fetcher } from "../api/fetcher" export type WEI = "WEI" | "wei" export type FRI = "FRI" | "fri" @@ -103,11 +102,6 @@ export type ApiTransactionSimulationResponseUnparsed = { simulationResults: ApiTransactionBulkSimulationResponse } -export interface TransactionSimulationWithFees { - simulation: ApiTransactionBulkSimulationResponse - feeEstimation: EstimatedFees -} - export type EventsToTrack = "Transfer" | "Approval" export type TransferEvent = Omit diff --git a/packages/extension/src/shared/transactions.ts b/packages/extension/src/shared/transactions.ts index f7f10ba2d..d385a948f 100644 --- a/packages/extension/src/shared/transactions.ts +++ b/packages/extension/src/shared/transactions.ts @@ -1,16 +1,17 @@ import { lowerCase, uniq, upperFirst } from "lodash-es" -import { Call, TransactionType, num } from "starknet" -import { SPEC } from "@starknet-io/types-js" +import type { Call, TransactionType } from "starknet" +import { num } from "starknet" +import type { SPEC } from "@starknet-io/types-js" -import { WalletAccount } from "./wallet.model" +import type { WalletAccount } from "./wallet.model" import { MultisigEntryPointType, MultisigTransactionType, } from "./multisig/types" import { getTransactionStatus } from "./transactions/utils" -import { Address } from "@argent/x-shared" -import { ActionQueueItemMeta } from "./actionQueue/schema" -import { TransactionSubmittedProperties } from "../ampli" +import type { Address } from "@argent/x-shared" +import type { ActionQueueItemMeta } from "./actionQueue/schema" +import type { TransactionSubmittedProperties } from "../ampli" export type FinaliyStatus = SPEC.TXN_STATUS export type ExecutionStatus = SPEC.TXN_EXECUTION_STATUS diff --git a/packages/extension/src/shared/transactions/getChangedStatusTransactions.test.ts b/packages/extension/src/shared/transactions/getChangedStatusTransactions.test.ts index 66471d1f2..939295357 100644 --- a/packages/extension/src/shared/transactions/getChangedStatusTransactions.test.ts +++ b/packages/extension/src/shared/transactions/getChangedStatusTransactions.test.ts @@ -1,4 +1,5 @@ -import { describe, it, expect, Mocked } from "vitest" +import type { Mocked } from "vitest" +import { describe, it, expect } from "vitest" import type { ExtendedTransactionStatus, Transaction } from "../transactions" import { getChangedStatusTransactions } from "./getChangedStatusTransactions" diff --git a/packages/extension/src/shared/transactions/getChangedStatusTransactions.ts b/packages/extension/src/shared/transactions/getChangedStatusTransactions.ts index 935dd39eb..440008bdb 100644 --- a/packages/extension/src/shared/transactions/getChangedStatusTransactions.ts +++ b/packages/extension/src/shared/transactions/getChangedStatusTransactions.ts @@ -1,6 +1,6 @@ import type { Transaction } from "../transactions" import { getTransactionStatus } from "./utils" -import { StorageChange } from "../storage/__new/interface" +import type { StorageChange } from "../storage/__new/interface" export function getChangedStatusTransactions( changeSet: StorageChange, diff --git a/packages/extension/src/shared/transactions/interface.ts b/packages/extension/src/shared/transactions/interface.ts index 89e372e9b..f773fbeaf 100644 --- a/packages/extension/src/shared/transactions/interface.ts +++ b/packages/extension/src/shared/transactions/interface.ts @@ -1,4 +1,4 @@ -import { TxHash } from "@argent/x-shared" +import type { TxHash } from "@argent/x-shared" export interface BaseTransaction { hash: TxHash diff --git a/packages/extension/src/shared/transactions/store.ts b/packages/extension/src/shared/transactions/store.ts index 6fa376329..d129cc6a5 100644 --- a/packages/extension/src/shared/transactions/store.ts +++ b/packages/extension/src/shared/transactions/store.ts @@ -1,15 +1,15 @@ import { differenceWith } from "lodash-es" import { ArrayStorage } from "../storage" -import { StorageChange } from "../storage/types" -import { +import type { StorageChange } from "../storage/types" +import type { Transaction, TransactionRequest, ExtendedTransactionStatus, - compareTransactions, } from "../transactions" +import { compareTransactions } from "../transactions" import { adaptArrayStorage } from "../storage/__new/repository" -import { IRepository } from "../storage/__new/interface" +import type { IRepository } from "../storage/__new/interface" import { checkTransactionHash, getTransactionStatus } from "./utils" /** diff --git a/packages/extension/src/shared/transactions/transactionHashes/transactionHashesRepository.ts b/packages/extension/src/shared/transactions/transactionHashes/transactionHashesRepository.ts new file mode 100644 index 000000000..349da8911 --- /dev/null +++ b/packages/extension/src/shared/transactions/transactionHashes/transactionHashesRepository.ts @@ -0,0 +1,45 @@ +import browser from "webextension-polyfill" +import { ChromeRepository } from "../../storage/__new/chrome" +import type { Hex } from "@argent/x-shared" +import { hexSchema } from "@argent/x-shared" + +export type TransactionHashMap = { + actionHash: string + transactionHash: Hex +} + +export const transactionHashesRepo = new ChromeRepository( + browser, + { + namespace: "core:transactionHashes", + areaName: "session", + compare: (a, b) => a.actionHash === b.actionHash, + }, +) + +export const addTransactionHash = async ( + actionHash: string, + transactionHash: string, +) => { + const transactionHashData: TransactionHashMap = { + actionHash, + transactionHash: hexSchema.parse(transactionHash), + } + await transactionHashesRepo.upsert(transactionHashData) + return transactionHashData +} + +export const getTransactionHash = async ( + actionHash: string, +): Promise => { + const [txHash] = await transactionHashesRepo.get( + (tx) => tx.actionHash === actionHash, + ) + + if (!txHash) { + console.error(`No txHash found for ${actionHash} Action Hash`) + return null + } + + return txHash +} diff --git a/packages/extension/src/shared/transactions/utils.test.ts b/packages/extension/src/shared/transactions/utils.test.ts index 1566c9d39..3c7ed09ff 100644 --- a/packages/extension/src/shared/transactions/utils.test.ts +++ b/packages/extension/src/shared/transactions/utils.test.ts @@ -3,8 +3,8 @@ import { getTransactionStatus, identifierToBaseTransaction, } from "./utils" -import { ExecutionStatus, ExtendedFinalityStatus } from "../transactions" -import { WalletAccount } from "../wallet.model" +import type { ExecutionStatus, ExtendedFinalityStatus } from "../transactions" +import type { WalletAccount } from "../wallet.model" import { describe, test, expect } from "vitest" describe("Make sure encode and decode to identifier return to the same value", () => { diff --git a/packages/extension/src/shared/transactions/utils.ts b/packages/extension/src/shared/transactions/utils.ts index e546fcace..b9589d00e 100644 --- a/packages/extension/src/shared/transactions/utils.ts +++ b/packages/extension/src/shared/transactions/utils.ts @@ -8,17 +8,17 @@ import { constants, num } from "starknet" import { TransactionError } from "../errors/transaction" import type { WalletAccount } from "../wallet.model" import type { BaseTransaction } from "./interface" -import { +import type { ExtendedFinalityStatus, ExtendedTransactionStatus, Transaction, ExecutionStatus, - SUCCESS_STATUSES, } from "../transactions" +import { SUCCESS_STATUSES } from "../transactions" import { z } from "zod" import { isSafeUpgradeTransaction } from "../utils/isSafeUpgradeTransaction" -import { NativeActivity } from "@argent/x-shared/simulation" -import { TransformedTransaction } from "../activity/utils/transform/type" +import type { NativeActivity } from "@argent/x-shared/simulation" +import type { TransformedTransaction } from "../activity/utils/transform/type" export function getTransactionIdentifier(transaction: BaseTransaction): string { return `${transaction.networkId}::${hexSchema.parse(transaction.hash)}` diff --git a/packages/extension/src/shared/types/deepPick.ts b/packages/extension/src/shared/types/deepPick.ts index 47a6f1bb9..6e67cc8ca 100644 --- a/packages/extension/src/shared/types/deepPick.ts +++ b/packages/extension/src/shared/types/deepPick.ts @@ -11,18 +11,18 @@ type DeepPath< > = CurrentDepth["length"] extends Depth ? never : T extends Record - ? { - [K in keyof T]: T[K] extends Record - ? - | K - | `${Extract}.${DeepPath< - T[K], - Depth, - [...CurrentDepth, 0] - >}` - : K - }[keyof T] - : never + ? { + [K in keyof T]: T[K] extends Record + ? + | K + | `${Extract}.${DeepPath< + T[K], + Depth, + [...CurrentDepth, 0] + >}` + : K + }[keyof T] + : never export type DeepPick< T, @@ -36,8 +36,8 @@ export type DeepPick< ? { [K in A]: DeepPick>> } : never : P extends keyof T - ? { [K in P]: T[P] } - : never + ? { [K in P]: T[P] } + : never }[K] > > diff --git a/packages/extension/src/shared/udc/schema.ts b/packages/extension/src/shared/udc/schema.ts index b0368d6a8..310adc89d 100644 --- a/packages/extension/src/shared/udc/schema.ts +++ b/packages/extension/src/shared/udc/schema.ts @@ -1,15 +1,15 @@ +import type { Address } from "@argent/x-shared" import { - Address, cairoAssemblySchema, compiledContractClassSchema, } from "@argent/x-shared" -import { +import type { CairoVersion, DeclareContractPayload, UniversalDeployerContractPayload, } from "starknet" import { z } from "zod" -import { BaseWalletAccount } from "../wallet.model" +import type { BaseWalletAccount } from "../wallet.model" export const getConstructorParamsSchema = z.object({ networkId: z.string(), @@ -38,8 +38,7 @@ export interface DeployContract { } export const declareContractSchema = z.object({ - address: z.string().optional(), - networkId: z.string().optional(), + accountId: z.string().optional(), contract: compiledContractClassSchema.or(z.string()), classHash: z.string().optional(), casm: cairoAssemblySchema.optional(), @@ -51,8 +50,7 @@ export type DeclareContractBackgroundPayload = z.infer< > export const deployContractSchema = z.object({ - address: z.string(), - networkId: z.string(), + accountId: z.string().optional(), classHash: z.string(), constructorCalldata: z.array(z.string()), salt: z.string().optional(), diff --git a/packages/extension/src/shared/udc/store.ts b/packages/extension/src/shared/udc/store.ts index 7c4f89c83..39122ec93 100644 --- a/packages/extension/src/shared/udc/store.ts +++ b/packages/extension/src/shared/udc/store.ts @@ -1,5 +1,6 @@ import { ArrayStorage } from "../storage" -import { Transaction, compareTransactions } from "../transactions" +import type { Transaction } from "../transactions" +import { compareTransactions } from "../transactions" export const declaredTransactionsStore = new ArrayStorage([], { namespace: "core:udcDeclaredTransactions", diff --git a/packages/extension/src/shared/ui/UIService.test.ts b/packages/extension/src/shared/ui/UIService.test.ts index e47ccfdff..0c6b1d081 100644 --- a/packages/extension/src/shared/ui/UIService.test.ts +++ b/packages/extension/src/shared/ui/UIService.test.ts @@ -14,7 +14,12 @@ describe("UIService", () => { }, runtime: { getURL: vi.fn(), - getManifest: vi.fn(() => ({ manifest_version: 3 }) as any), + getManifest: vi.fn( + () => + ({ + manifest_version: 3, + }) as any, + ), }, tabs: { create: vi.fn(), diff --git a/packages/extension/src/shared/ui/UIService.ts b/packages/extension/src/shared/ui/UIService.ts index 98b110466..db5dcf5dd 100644 --- a/packages/extension/src/shared/ui/UIService.ts +++ b/packages/extension/src/shared/ui/UIService.ts @@ -1,7 +1,8 @@ -import { MinimalActionBrowser, getBrowserAction } from "../browser" -import { DeepPick } from "../types/deepPick" +import type { MinimalActionBrowser } from "../browser" +import { getBrowserAction } from "../browser" +import type { DeepPick } from "../types/deepPick" import { UI_SERVICE_CONNECT_ID } from "./constants" -import { IUIService } from "./IUIService" +import type { IUIService } from "./IUIService" type MinimalBrowser = DeepPick< typeof chrome, diff --git a/packages/extension/src/shared/ui/constants.ts b/packages/extension/src/shared/ui/constants.ts index 33d4d8037..bb15269a4 100644 --- a/packages/extension/src/shared/ui/constants.ts +++ b/packages/extension/src/shared/ui/constants.ts @@ -1 +1,3 @@ export const UI_SERVICE_CONNECT_ID = "argent-x-ui-service-connect" + +export const ENABLE_TOKEN_DETAILS = process.env.ENABLE_TOKEN_DETAILS === "true" diff --git a/packages/extension/src/shared/ui/routes.ts b/packages/extension/src/shared/ui/routes.ts index 3e0a8f85a..686cd29a1 100644 --- a/packages/extension/src/shared/ui/routes.ts +++ b/packages/extension/src/shared/ui/routes.ts @@ -4,7 +4,7 @@ import type { AddressBookContact } from "../addressBook/type" import type { Flow } from "../argentAccount/schema" import type { LedgerStartContext } from "../ledger/schema" import type { SendQuery } from "../send/schema" -import type { CreateAccountType, SignerType } from "../wallet.model" +import type { CreateAccountType, SignerType, AccountId } from "../wallet.model" export const route = string>( ...[value, path]: [routeAndPath: string] | [routeWithParams: T, path: string] @@ -58,26 +58,36 @@ export const routes = { accountDiscover: route("/account/discover"), beforeYouContinue: route("/before-you-continue"), collectionNfts: route( - (contractAddress: string) => `/account/collection/${contractAddress}`, + (contractAddress: string, returnTo?: string) => + returnTo + ? `/account/collection/${contractAddress}?returnTo=${encodeURIComponent( + returnTo, + )}` + : `/account/collection/${contractAddress}`, `/account/collection/:contractAddress`, ), accountNft: route( - (contractAddress: string, tokenId: string) => - `/account/nfts/${contractAddress}/${tokenId}`, + (contractAddress: string, tokenId: string, returnTo?: string) => + returnTo + ? `/account/nfts/${contractAddress}/${tokenId}?returnTo=${encodeURIComponent( + returnTo, + )}` + : `/account/nfts/${contractAddress}/${tokenId}`, `/account/nfts/:contractAddress/:tokenId`, ), - accountHideConfirm: route( - (accountAddress: string) => `/account/hide-confirm/${accountAddress}`, - `/account/hide-confirm/:accountAddress`, - ), - accountDeleteConfirm: route( - (accountAddress: string) => `/account/delete-confirm/${accountAddress}`, - `/account/delete-confirm/:accountAddress`, + accountHideOrDeleteConfirm: route( + (accountId: AccountId, mode: "hide" | "remove" | "delete") => + `/account/hide-or-remove-confirm/${accountId}/${mode}`, + `/account/hide-or-remove-confirm/:accountId/:mode`, ), sendRecipientScreen: route( (query: SendQuery) => `/send?${qs(query)}`, "/send", ), + sendAddressBookEdit: route( + (contact?: AddressBookContact) => `/send/address-book/edit?${qs(contact)}`, + "/send/address-book/edit", + ), sendAmountAndAssetScreen: route( (query: SendQuery) => `/send/amount-and-asset/?${qs(query)}`, "/send/amount-and-asset", @@ -109,40 +119,44 @@ export const routes = { "/accounts/hidden/:networkId", ), accounts: routeWithReturnTo("/accounts"), - newAccount: routeWithReturnTo("/accounts/new"), + newAccount: route( + (networkId: string, returnTo?: string) => + returnTo + ? `/accounts/new/${networkId}?returnTo=${encodeURIComponent(returnTo)}` + : `/accounts/new/${networkId}`, + "/accounts/new/:networkId", + ), changeAccountImplementations: route( - (accountAddress) => `/accounts/${accountAddress}/change-implementation`, - "/accounts/:accountAddress/change-implementation", + (accountId) => `/accounts/${accountId}/change-implementation`, + "/accounts/:accountId/change-implementation", ), accountImplementation: route( - (accountAddress) => `/accounts/${accountAddress}/implementation`, - "/accounts/:accountAddress/implementation", + (accountId) => `/accounts/${accountId}/implementation`, + "/accounts/:accountId/implementation", ), addAccount: route("/accounts/new"), standardAccountSignerSelection: route("/account/standard/signer-selection"), smartAccountStart: route( - (accountAddress) => `/accounts/${accountAddress}/smartAccount`, - "/accounts/:accountAddress/smartAccount", + (accountId) => `/accounts/${accountId}/smartAccount`, + "/accounts/:accountId/smartAccount", ), argentAccountEmail: route( - (accountAddress, flow: Flow, returnTo?: string) => + (accountId, flow: Flow, returnTo?: string) => returnTo - ? `/accounts/${accountAddress}/${flow}/email?returnTo=${encodeURIComponent( + ? `/accounts/${accountId}/${flow}/email?returnTo=${encodeURIComponent( returnTo, )}` - : `/accounts/${accountAddress}/${flow}/email`, - "/accounts/:accountAddress/:flow/email", + : `/accounts/${accountId}/${flow}/email`, + "/accounts/:accountId/:flow/email", ), argentAccountLoggedIn: route( - (accountAddress) => `/accounts/${accountAddress}/logged-in`, - "/accounts/:accountAddress/logged-in", + (accountId) => `/accounts/${accountId}/logged-in`, + "/accounts/:accountId/logged-in", ), smartAccountOTP: route( - (accountAddress: string, email: string, flow: Flow) => - `/accounts/${accountAddress}/${flow}/otp?email=${encodeURIComponent( - email, - )}`, - "/accounts/:accountAddress/:flow/otp", + (accountId: AccountId, email: string, flow: Flow) => + `/accounts/${accountId}/${flow}/otp?email=${encodeURIComponent(email)}`, + "/accounts/:accountId/:flow/otp", ), createSmartAccountOTP: route( (email: string, flow: Flow) => @@ -157,31 +171,38 @@ export const routes = { "/accounts/email", ), smartAccountAction: route( - (accountAddress) => `/accounts/${accountAddress}/smartAccount/action`, - "/accounts/:accountAddress/smartAccount/action", + (accountId) => `/accounts/${accountId}/smartAccount/action`, + "/accounts/:accountId/smartAccount/action", ), smartAccountFinish: route( - (accountAddress) => `/accounts/${accountAddress}/smartAccount/finish`, - "/accounts/:accountAddress/smartAccount/finish", + (accountId) => `/accounts/${accountId}/smartAccount/finish`, + "/accounts/:accountId/smartAccount/finish", ), smartAccountEscapeWarning: route( - (accountAddress) => - `/accounts/${accountAddress}/smartAccount/escape-warning`, - "/accounts/:accountAddress/smartAccount/escape-warning", + (accountId) => `/accounts/${accountId}/smartAccount/escape-warning`, + "/accounts/:accountId/smartAccount/escape-warning", ), newToken: route("/tokens/new"), funding: route("/funding"), fundingBridge: route("/funding/bridge"), exportPrivateKey: route( - (accountAddress) => `/export-private-key/${accountAddress}`, - "/export-private-key/:accountAddress", + (accountId, type) => `/export-private-key/${accountId}/${type}`, + "/export-private-key/:accountId/:type", ), exportPublicKey: route( - (accountAddress) => `/export-public-key/${accountAddress}`, - "/export-public-key/:accountAddress", + (accountId) => `/export-public-key/${accountId}`, + "/export-public-key/:accountId", ), fundingQrCode: route("/funding/qr-code"), - fundingProvider: route("/funding/provider"), + fundingProvider: route( + (tokenAddress?: string, returnTo?: string) => + returnTo + ? `/funding/provider/${tokenAddress}?returnTo=${encodeURIComponent( + returnTo, + )}` + : `/funding/provider/${tokenAddress}`, + "/funding/provider/:tokenAddress?", + ), fundingFaucetFallback: route("/funding/faucet-fallback"), fundingFaucetSepolia: route("/funding/faucet-sepolia"), hideToken: route( @@ -189,20 +210,20 @@ export const routes = { "/tokens/:tokenAddress/hide", ), addPlugin: route( - (accountAddress) => `/add-plugin/${accountAddress}`, - "/add-plugin/:accountAddress", + (accountId) => `/add-plugin/${accountId}`, + "/add-plugin/:accountId", ), reset: route("/reset"), legacy: route("/legacy"), settings: routeWithReturnTo("/settings"), settingsAccount: route( - (accountAddress, returnTo?: string) => + (accountId: AccountId, returnTo?: string) => returnTo - ? `/settings/account/${accountAddress}?returnTo=${encodeURIComponent( + ? `/settings/account/${accountId}?returnTo=${encodeURIComponent( returnTo, )}` - : `/settings/account/${accountAddress}`, - "/settings/account/:accountAddress", + : `/settings/account/${accountId}`, + "/settings/account/:accountId", ), settingsPreferences: routeWithReturnTo("/settings/preferences"), settingsBlockExplorer: routeWithReturnTo( @@ -211,35 +232,37 @@ export const routes = { settingsNftMarketplace: routeWithReturnTo( "/settings/preferences/nft-marketplace", ), - settingsNetworks: route("/settings/developer-settings/networks"), + settingsIdProvider: routeWithReturnTo("/settings/preferences/id-provider"), + settingsHiddenAndSpamTokens: routeWithReturnTo( + "/settings/preferences/hidden-and-spam-tokens", + ), + settingsNetworks: route("/settings/advanced/networks"), settingsSeed: routeWithReturnTo("/settings/seed"), settingsAutoLockTimer: routeWithReturnTo("/settings/auto-lock-timer"), - settingsAddCustomNetwork: route("/settings/developer-settings/networks/add"), + settingsAddCustomNetwork: route("/settings/advanced/networks/add"), settingsEditCustomNetwork: route( - (networkId) => `/settings/developer-settings/networks/${networkId}/edit`, - "/settings/developer-settings/networks/:networkId/edit", - ), - settingsRemoveCustomNetwork: route( - "/settings/developer-settings/networks/remove", + (networkId) => `/settings/advanced/networks/${networkId}/edit`, + "/settings/advanced/networks/:networkId/edit", ), + settingsRemoveCustomNetwork: route("/settings/advanced/networks/remove"), settingsDappConnectionsAccountList: route("/settings/dapp-connections"), settingsDappConnectionsAccount: route( - (accountAddress) => `/settings/dapp-connections/${accountAddress}`, - "/settings/dapp-connections/:accountAddress", + (accountId) => `/settings/dapp-connections/${accountId}`, + "/settings/dapp-connections/:accountId", + ), + settingsSecurityAndRecovery: routeWithReturnTo( + "/settings/security-and-recovery", ), settingsPrivacy: routeWithReturnTo("/settings/privacy"), - settingsDeveloper: route("/settings/developer-settings"), - settingsExperimental: route("/settings/developer-settings/experimental"), - settingsBetaFeatures: route("/settings/developer-settings/beta-features"), + settingsAdvanced: route("/settings/advanced"), + settingsExperimental: route("/settings/advanced/experimental"), + settingsBetaFeatures: route("/settings/advanced/beta-features"), settingsAddressBook: route("/settings/addressbook"), settingsAddressBookAddOrEdit: route( (contact?: AddressBookContact) => `/settings/addressbook/add-or-edit?${qs(contact)}`, "/settings/addressbook/add-or-edit", ), - settingsSmartContractDevelopment: route( - "/settings/smart-contract-development", - ), settingsClearLocalStorage: route("/settings/clear-local-storage"), settingsDownloadLogs: route("/settings/download-logs"), deploymentData: route("/settings/deployment-data"), @@ -269,8 +292,10 @@ export const routes = { accountType: CreateAccountType, networkId: string, ctx: LedgerStartContext, - ) => `/ledger/connect/${accountType}/${networkId}/${ctx}`, - "/ledger/connect/:accountType/:networkId/:ctx", + signerToReplace?: string, + ) => + `/ledger/connect/${accountType}/${networkId}/${ctx}/${signerToReplace}`, + "/ledger/connect/:accountType/:networkId/:ctx/:signerToReplace", ), ledgerSelect: route("/ledger/select"), ledgerDone: route("/ledger/done"), @@ -279,8 +304,9 @@ export const routes = { multisigNew: route("/account/new/multisig"), multisigSetup: route("/multisig/setup"), multisigSignerSelection: route( - (ctx: "create" | "join") => `/multisig/signer-selection/${ctx}`, - "/multisig/signer-selection/:ctx", + (ctx: "create" | "join" | "replace", signerToReplace?: string) => + `/multisig/signer-selection/${ctx}/${signerToReplace}`, + "/multisig/signer-selection/:ctx/:signerToReplace", ), multisigCreate: route( (networkId: string, creatorType: SignerType) => @@ -301,66 +327,110 @@ export const routes = { "/multisig/join/:publicKey/settings", ), multisigOwners: route( - (accountAddress) => `/multisig/${accountAddress}/owners`, - "/multisig/:accountAddress/owners", + (accountId) => `/multisig/${accountId}/owners`, + "/multisig/:accountId/owners", ), multisigConfirmations: route( - (accountAddress) => `/multisig/${accountAddress}/confirmations`, - "/multisig/:accountAddress/confirmations", + (accountId) => `/multisig/${accountId}/confirmations`, + "/multisig/:accountId/confirmations", ), multisigAddOwners: route( - (accountAddress) => `/multisig/${accountAddress}/add-owners`, - "/multisig/:accountAddress/add-owners", + (accountId) => `/multisig/${accountId}/add-owners`, + "/multisig/:accountId/add-owners", ), multisigRemoveOwners: route( - (accountAddress, signerToRemove) => - `/multisig/${accountAddress}/${signerToRemove}/remove-owners`, - "/multisig/:accountAddress/:signerToRemove/remove-owners", + (accountId, signerToRemove) => + `/multisig/${accountId}/${signerToRemove}/remove-owners`, + "/multisig/:accountId/:signerToRemove/remove-owners", ), multisigReplaceOwner: route( - (accountAddress, signerToReplace) => - `/multisig/${accountAddress}/${signerToReplace}/replace-owner`, - "/multisig/:accountAddress/:signerToReplace/replace-owner", + (accountId, signerToReplace) => + `/multisig/${accountId}/${signerToReplace}/replace-owner`, + "/multisig/:accountId/:signerToReplace/replace-owner", ), multisigPendingTransactionDetails: route( - (accountAddress, requestId, returnTo?: string) => + (accountId, requestId, returnTo?: string) => returnTo - ? `/multisig/transactions/${accountAddress}/${requestId}/details?returnTo=${encodeURIComponent( + ? `/multisig/transactions/${accountId}/${requestId}/details?returnTo=${encodeURIComponent( returnTo, )}` - : `/multisig/transactions/${accountAddress}/${requestId}/details`, - "/multisig/transactions/:accountAddress/:requestId/details", + : `/multisig/transactions/${accountId}/${requestId}/details`, + "/multisig/transactions/:accountId/:requestId/details", ), multisigTransactionConfirmations: route( - (accountAddress, requestId, transactionType: "pending" | "activity") => - `/multisig/${accountAddress}/${requestId}/${transactionType}/confirmations`, - "/multisig/:accountAddress/:requestId/:transactionType/confirmations", + (accountId, requestId, transactionType: "pending" | "activity") => + `/multisig/${accountId}/${requestId}/${transactionType}/confirmations`, + "/multisig/:accountId/:requestId/:transactionType/confirmations", ), multisigRemovedSettings: route( - (accountAddress: string, returnTo?: string) => + (accountId: AccountId, returnTo?: string) => returnTo - ? `/multisig/removed/${accountAddress}/settings?returnTo=${encodeURIComponent( + ? `/multisig/removed/${accountId}/settings?returnTo=${encodeURIComponent( returnTo, )}` - : `/multisig/removed/${accountAddress}/settings`, - "/multisig/removed/:accountAddress/settings", + : `/multisig/removed/${accountId}/settings`, + "/multisig/removed/:accountId/settings", ), multisigPendingOffchainSignatureDetails: route( - (accountAddress, requestId) => - `/multisig/signatures/${accountAddress}/${requestId}/details`, - "/multisig/signatures/:accountAddress/:requestId/details", + (accountId, requestId) => + `/multisig/signatures/${accountId}/${requestId}/details`, + "/multisig/signatures/:accountId/:requestId/details", + ), + + tokenDetails: route( + (address, networkId, returnTo = routes.accountTokens()) => + `/token/${address}/${networkId}?returnTo=${encodeURIComponent(returnTo)}`, + "/token/:address/:networkId", ), multisigPendingOffchainSignatureConfirmations: route( - (accountAddress, requestId) => - `/multisig/signatures/${accountAddress}/${requestId}/confirmations`, - "/multisig/signatures/:accountAddress/:requestId/confirmations", + (accountId, requestId) => + `/multisig/signatures/${accountId}/${requestId}/confirmations`, + "/multisig/signatures/:accountId/:requestId/confirmations", ), multisigOffchainSignatureWarning: route("/multisig/signatures/warning"), airGapReview: route((data) => `/airgap/${data}`, "/airgap/:data"), - swap: route("/swap"), + privateKeyImport: routeWithReturnTo("/account/pk-import"), + swapToken: route( + (tokenAddress?: string, returnTo?: string) => + returnTo + ? `/swap${tokenAddress ? `/${tokenAddress}` : ""}?returnTo=${encodeURIComponent(returnTo)}` + : `/swap${tokenAddress ? `/${tokenAddress}` : ""}`, + "/swap/:tokenAddress?", + ), + + // Staking + staking: routeWithReturnTo("/staking"), + nativeStakingIndex: routeWithReturnTo("/staking/native"), // allows router to resolve without investmentId + nativeStaking: route( + (investmentId: string, returnTo?: string) => + returnTo + ? `/staking/native/${investmentId}?returnTo=${encodeURIComponent(returnTo)}` + : `/staking/native/${investmentId}`, + "/staking/native/:investmentId", + ), + nativeStakingSelect: routeWithReturnTo("/staking/native-select"), + liquidStakingSelect: routeWithReturnTo("/staking/liquid-select"), + unstake: route( + (investmentPositionId: string, returnTo?: string) => + returnTo + ? `/staking/unstake/${investmentPositionId}?returnTo=${encodeURIComponent(returnTo)}` + : `/staking/unstake/${investmentPositionId}`, + "/staking/unstake/:investmentPositionId", + ), + + // Defi + defiPositionDetails: route( + (positionId: string, dappId: string, returnTo?: string) => + returnTo + ? `/defi/position/${positionId}/${dappId}?returnTo=${encodeURIComponent( + returnTo, + )}` + : `/defi/position/${positionId}/${dappId}`, + "/defi/position/:positionId/:dappId", + ), } diff --git a/packages/extension/src/shared/utils/accountIdentifier.test.ts b/packages/extension/src/shared/utils/accountIdentifier.test.ts new file mode 100644 index 000000000..0e2240940 --- /dev/null +++ b/packages/extension/src/shared/utils/accountIdentifier.test.ts @@ -0,0 +1,129 @@ +import { + deserializeAccountIdentifier, + getAccountIdentifier, +} from "./accountIdentifier" +import type { WalletAccountSigner } from "../wallet.model" +import { accountIdSchema, SignerType } from "../wallet.model" +import { addressSchema } from "@argent/x-shared" + +describe("accountIdentifier", () => { + describe("getAccountIdentifier", () => { + const mockSigner: WalletAccountSigner = { + type: SignerType.LOCAL_SECRET, + derivationPath: "m/44'/60'/0'/0/0", + } + const addr = + "0x50bdb26374ba0dace3dba7243328df82fed8cdfa698d89992d06cdd520ab768" + const networkId = "sepolia-alpha" + + it("should return the correct account identifier with parsing", () => { + vi.spyOn(addressSchema, "parse") + const result = getAccountIdentifier(addr, networkId, mockSigner) + expect(result).toBe( + "0x050bdb26374ba0dace3dba7243328df82fed8cdfa698d89992d06cdd520ab768::sepolia-alpha::local_secret::0", + ) + expect(addressSchema.parse).toHaveBeenCalledWith(addr) + expect(accountIdSchema.parse(result)).toBeTruthy() + }) + + it("should return the correct account identifier without parsing", () => { + vi.spyOn(addressSchema, "parse") + const result = getAccountIdentifier(addr, networkId, mockSigner, false) + expect(result).toBe( + "0x50bdb26374ba0dace3dba7243328df82fed8cdfa698d89992d06cdd520ab768::sepolia-alpha::local_secret::0", + ) + expect(addressSchema.parse).not.toHaveBeenCalled() + expect(accountIdSchema.parse(result)).toBeTruthy() + }) + + it("should fail with zero address", () => { + vi.spyOn(addressSchema, "parse") + const addr = "0x0" + expect(() => + getAccountIdentifier(addr, networkId, mockSigner), + ).toThrowError() + expect(addressSchema.parse).toHaveBeenCalledWith(addr) + }) + + it("should pass with zero address if parsing is disabled", () => { + // This case is required when creating smart accounts because we don't know the address + // in advance. + vi.spyOn(addressSchema, "parse") + const addr = "0x0" + const result = getAccountIdentifier(addr, networkId, mockSigner, false) + expect(result).toBe("0x0::sepolia-alpha::local_secret::0") + expect(addressSchema.parse).not.toHaveBeenCalled() + expect(accountIdSchema.parse(result)).toBeTruthy() + }) + + it("should handle different network IDs", () => { + const networkId = "mainnet-alpha" + const result = getAccountIdentifier(addr, networkId, mockSigner) + expect(result).toBe( + "0x050bdb26374ba0dace3dba7243328df82fed8cdfa698d89992d06cdd520ab768::mainnet-alpha::local_secret::0", + ) + expect(accountIdSchema.parse(result)).toBeTruthy() + }) + + it("should handle different signer types", () => { + const networkId = "mainnet-alpha" + const customSigner = { ...mockSigner, type: SignerType.LEDGER } + const result = getAccountIdentifier(addr, networkId, customSigner) + expect(result).toBe( + "0x050bdb26374ba0dace3dba7243328df82fed8cdfa698d89992d06cdd520ab768::mainnet-alpha::ledger::0", + ) + expect(accountIdSchema.parse(result)).toBeTruthy() + }) + }) + + describe("deserializeAccountIdentifier", () => { + it("should correctly deserialize a valid account identifier", () => { + const accountId = + "0x050bdb26374ba0dace3dba7243328df82fed8cdfa698d89992d06cdd520ab768::sepolia-alpha::local_secret::0" + const result = deserializeAccountIdentifier(accountId) + + expect(result).toEqual({ + address: + "0x050bdb26374ba0dace3dba7243328df82fed8cdfa698d89992d06cdd520ab768", + networkId: "sepolia-alpha", + signer: { + type: SignerType.LOCAL_SECRET, + index: 0, + }, + }) + }) + + it("should handle different network IDs", () => { + const accountId = + "0x050bdb26374ba0dace3dba7243328df82fed8cdfa698d89992d06cdd520ab768::mainnet-alpha::local_secret::0" + const result = deserializeAccountIdentifier(accountId) + + expect(result.networkId).toBe("mainnet-alpha") + }) + + it("should handle different signer types", () => { + const accountId = + "0x050bdb26374ba0dace3dba7243328df82fed8cdfa698d89992d06cdd520ab768::sepolia-alpha::ledger::0" + const result = deserializeAccountIdentifier(accountId) + + expect(result.signer.type).toBe(SignerType.LEDGER) + }) + + it("should handle different signer indices", () => { + const accountId = + "0x050bdb26374ba0dace3dba7243328df82fed8cdfa698d89992d06cdd520ab768::sepolia-alpha::local_secret::5" + const result = deserializeAccountIdentifier(accountId) + + expect(result.signer.index).toBe(5) + }) + + it("should throw an error for invalid account identifiers", () => { + const invalidAccountId = + "0x050bdb26374ba0dace3dba7243328df82fed8cdfa698d89992d06cdd520ab768::sepolia-alpha::local_secret" + + expect(() => deserializeAccountIdentifier(invalidAccountId)).toThrowError( + "Invalid account identifier", + ) + }) + }) +}) diff --git a/packages/extension/src/shared/utils/accountIdentifier.ts b/packages/extension/src/shared/utils/accountIdentifier.ts new file mode 100644 index 000000000..2908e4aa7 --- /dev/null +++ b/packages/extension/src/shared/utils/accountIdentifier.ts @@ -0,0 +1,50 @@ +import { addressSchema } from "@argent/x-shared" +import type { AccountId, WalletAccountSigner } from "../wallet.model" +import { accountIdSchema, SignerType } from "../wallet.model" +import { getIndexForPathUnsafe } from "./derivationPath" +import { stark } from "starknet" + +export function getAccountIdentifier( + address: string, + networkId: string, + signer: WalletAccountSigner, + parse = true, +): AccountId { + const addr = parse ? addressSchema.parse(address) : address + const signerIndex = getIndexForPathUnsafe(signer.derivationPath) + const id = `${addr}::${networkId}::${signer.type}::${signerIndex}` + + return accountIdSchema.parse(id) +} + +// Test util +export function getRandomAccountIdentifier( + address?: string, + networkId?: string, + signer?: WalletAccountSigner, +) { + address = address ?? stark.randomAddress() + networkId = networkId ?? "sepolia-alpha" + const signerType = signer?.type ?? SignerType.LOCAL_SECRET + const signerIndex = signer?.derivationPath + ? getIndexForPathUnsafe(signer.derivationPath) + : 0 + + return `${address}::${networkId}::${signerType}::${signerIndex}` +} + +export const deserializeAccountIdentifier = (id: AccountId) => { + const parts = id.split("::") + if (parts.length !== 4) { + throw new Error("Invalid account identifier") + } + + return { + address: parts[0], + networkId: parts[1], + signer: { + type: parts[2] as SignerType, + index: parseInt(parts[3]), + }, + } +} diff --git a/packages/extension/src/shared/utils/accountsEqual.test.ts b/packages/extension/src/shared/utils/accountsEqual.test.ts new file mode 100644 index 000000000..4301c1a94 --- /dev/null +++ b/packages/extension/src/shared/utils/accountsEqual.test.ts @@ -0,0 +1,133 @@ +import { describe, it, expect } from "vitest" +import { accountsEqual } from "./accountsEqual" + +describe("accountsEqual", () => { + const accountA = { + address: + "0x4da6b031d730282c1861e6f72c3dcecb063d7d90f27c9bafab980c5c8f0ccc1", + cairoVersion: "1", + classHash: + "0x029927c8af6bccf3f6fda035981e765a7bdbf18a2dc0d630494f8758aa908e2b", + name: "Account 1", + needsDeploy: false, + networkId: "mainnet-alpha", + provisionAmount: "111100000000000000000", + provisionDate: 1709128880978, + signer: { + derivationPath: "m/44'/9004'/0'/0/0", + type: "local_secret", + }, + type: "standard", + } + + const accountB = { + address: + "0x04da6b031d730282c1861e6f72c3dcecb063d7d90f27c9bafab980c5c8f0ccc1", + classHash: + "0x029927c8af6bccf3f6fda035981e765a7bdbf18a2dc0d630494f8758aa908e2b", + id: "0x04da6b031d730282c1861e6f72c3dcecb063d7d90f27c9bafab980c5c8f0ccc1::mainnet-alpha::local_secret::0", + name: "Account 1", + needsDeploy: false, + networkId: "mainnet-alpha", + signer: { + derivationPath: "m/44'/9004'/0'/0/0", + type: "local_secret", + }, + type: "standard", + } + + it("should return true for accounts with the same id", () => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + expect(accountsEqual(accountA, accountB)).toBe(true) + }) + + it("should return true for accounts with the same address and networkId", () => { + const accountC = { + address: + "0x4da6b031d730282c1861e6f72c3dcecb063d7d90f27c9bafab980c5c8f0ccc1", + networkId: "mainnet-alpha", + signer: { + derivationPath: "m/44'/9004'/0'/0/0", + type: "local_secret", + }, + } + + const accountD = { + address: + "0x4da6b031d730282c1861e6f72c3dcecb063d7d90f27c9bafab980c5c8f0ccc1", + networkId: "mainnet-alpha", + signer: { + derivationPath: "m/44'/9004'/0'/0/0", + type: "local_secret", + }, + } + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + expect(accountsEqual(accountC, accountD)).toBe(true) + }) + + it("should return true for accounts that determine same id", () => { + const accountE = { + id: "0x04da6b031d730282c1861e6f72c3dcecb063d7d90f27c9bafab980c5c8f0ccc1::mainnet-alpha::local_secret::1", + address: + "0x4da6b031d730282c1861e6f72c3dcecb063d7d90f27c9bafab980c5c8f0ccc1", + networkId: "mainnet-alpha", + signer: { + derivationPath: "m/44'/9004'/0'/0/0", + type: "local_secret", + }, + } + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + expect(accountsEqual(accountA, accountE)).toBe(true) + }) + + it("should return false for accounts with different addresses", () => { + const accountF = { + id: "0x04da6b031d730282c1861e6f72c3dcecb063d7d90f27c9bafab980c5c8f0ccc1::mainnet-alpha::local_secret::0", + address: "0x456", + networkId: "mainnet-alpha", + signer: { + derivationPath: "m/44'/9004'/0'/0/0", + type: "local_secret", + }, + } + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + expect(accountsEqual(accountA, accountF)).toBe(false) + }) + + it("should return false for accounts with different networkIds", () => { + const accountG = { + id: "0x04da6b031d730282c1861e6f72c3dcecb063d7d90f27c9bafab980c5c8f0ccc1::mainnet-alpha::local_secret::0", + address: + "0x4da6b031d730282c1861e6f72c3dcecb063d7d90f27c9bafab980c5c8f0ccc1", + networkId: "testnet", + signer: { + derivationPath: "m/44'/9004'/0'/0/0", + type: "local_secret", + }, + } + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + expect(accountsEqual(accountA, accountG)).toBe(false) + }) + + it("should return false if one of the accounts is undefined", () => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + expect(accountsEqual(accountA, undefined)).toBe(false) + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + expect(accountsEqual(undefined, accountB)).toBe(false) + }) + + it("should return false if both accounts are undefined", () => { + expect(accountsEqual(undefined, undefined)).toBe(false) + }) +}) diff --git a/packages/extension/src/shared/utils/accountsEqual.ts b/packages/extension/src/shared/utils/accountsEqual.ts index a532a854f..54268e461 100644 --- a/packages/extension/src/shared/utils/accountsEqual.ts +++ b/packages/extension/src/shared/utils/accountsEqual.ts @@ -1,15 +1,24 @@ import { isEqualAddress } from "@argent/x-shared" -import type { BaseWalletAccount, WalletAccount } from "../wallet.model" +import type { + AccountId, + BaseWalletAccount, + WalletAccount, +} from "../wallet.model" +import { getAccountIdentifier } from "./accountIdentifier" -/** prevents infinite re-renders when both accounts are undefined */ -export const atomFamilyAccountsEqual = ( - a?: BaseWalletAccount, - b?: BaseWalletAccount, -) => { - if (!a && !b) { - return true +export const accountsEqualByChainId = (a: WalletAccount, b?: WalletAccount) => { + try { + if (!b) { + return false + } + return ( + isEqualAddress(a.address, b.address) && + a.network.chainId === b.network.chainId + ) + } catch (e) { + console.error("~ accountsEqualByChainId", e) + return false } - return accountsEqual(a, b) } export const accountsEqual = (a?: BaseWalletAccount, b?: BaseWalletAccount) => { @@ -17,24 +26,69 @@ export const accountsEqual = (a?: BaseWalletAccount, b?: BaseWalletAccount) => { if (!a || !b) { return false } + + // for accounts that have an id + if (a.id && b.id) { + return isEqualAccountIds(a.id, b.id) + } + + // where there is not yet an id but one can be determined + if ("signer" in a && "signer" in b) { + const aId = getAccountIdentifier(a.address, a.networkId, a.signer as any) + const bId = getAccountIdentifier(b.address, b.networkId, b.signer as any) + return isEqualAccountIds(aId, bId) + } + + // fallback to basic comparison return isEqualAddress(a.address, b.address) && a.networkId === b.networkId } catch (e) { - console.error("~ accountsEqual", e) + console.error("~ accountsEqualById", e) return false } } -export const accountsEqualByChainId = (a: WalletAccount, b?: WalletAccount) => { +export const isEqualAccountIds = (a?: AccountId, b?: AccountId) => { + try { + if (!a || !b) { + return false + } + + return a === b + } catch (e) { + console.error("~ accountsEqualById", e) + return false + } +} + +/** prevents infinite re-renders when both accounts are undefined */ +export const atomFamilyAccountsEqual = ( + a?: BaseWalletAccount, + b?: BaseWalletAccount, +) => { + if (!a && !b) { + return true + } + return accountsEqual(a, b) +} + +export const atomFamilyIsEqualAccountIds = (a?: AccountId, b?: AccountId) => { + if (!a && !b) { + return true + } + return isEqualAccountIds(a, b) +} + +export const accountsEqualByAddress = ( + a: Omit, + b?: Omit, +) => { try { if (!b) { return false } - return ( - isEqualAddress(a.address, b.address) && - a.network.chainId === b.network.chainId - ) + return isEqualAddress(a.address, b.address) && a.networkId === b.networkId } catch (e) { - console.error("~ accountsEqualByChainId", e) + console.error("~ accountsEqualByAddress", e) return false } } diff --git a/packages/extension/src/shared/utils/accountsMultisigSort.ts b/packages/extension/src/shared/utils/accountsMultisigSort.ts index 9dabb1438..b1389390e 100644 --- a/packages/extension/src/shared/utils/accountsMultisigSort.ts +++ b/packages/extension/src/shared/utils/accountsMultisigSort.ts @@ -1,5 +1,5 @@ -import { getIndexForPath } from "./derivationPath" -import { PendingMultisig } from "../multisig/types" +import { getIndexForPath, getIndexForPathUnsafe } from "./derivationPath" +import type { PendingMultisig } from "../multisig/types" import { SignerType } from "../wallet.model" import { getBaseDerivationPath } from "../signer/utils" @@ -13,6 +13,7 @@ interface SortableAccount { const signerPriority: { [key in SignerType]: number } = { [SignerType.LOCAL_SECRET]: 0, [SignerType.LEDGER]: 1, + [SignerType.PRIVATE_KEY]: 2, } export const sortAccountsByDerivationPath = ( @@ -78,3 +79,19 @@ export const sortMultisigAndPendingMultisigAccounts = < .sort(sortMultisigByDerivationPath) .sort(sortAccountsBySignerPriority) } + +export const sortImportedAccountsByIndex = ( + a: SortableAccount, + b: SortableAccount, +) => { + const aIndex = getIndexForPathUnsafe(a.signer.derivationPath) + const bIndex = getIndexForPathUnsafe(b.signer.derivationPath) + + return aIndex - bIndex +} + +export const sortImportedAccounts = ( + accounts: T[], +): T[] => { + return [...accounts].sort(sortImportedAccountsByIndex) +} diff --git a/packages/extension/src/shared/utils/argentAccountVersion.ts b/packages/extension/src/shared/utils/argentAccountVersion.ts index 461292b6a..3146cdae9 100644 --- a/packages/extension/src/shared/utils/argentAccountVersion.ts +++ b/packages/extension/src/shared/utils/argentAccountVersion.ts @@ -1,16 +1,18 @@ -import { CairoVersion, shortString } from "starknet" -import { Network, getProvider } from "../network" +import type { CairoVersion } from "starknet" +import { shortString } from "starknet" +import type { Network } from "../network" +import { getProvider } from "../network" import semver from "semver" -import { ArgentAccountType } from "../wallet.model" +import type { WalletAccountType } from "../wallet.model" import { getMulticallForNetwork } from "../multicall" export async function getAccountCairoVersion( accountAddress: string, network: Network, - type: ArgentAccountType = "standard", + type: WalletAccountType = "standard", ): Promise { try { - if (type === "multisig" || type === "smart") { + if (type === "multisig" || type === "smart" || type === "imported") { return "1" // Only Cairo version 1 is supported for multisig and smart accounts } @@ -42,7 +44,7 @@ export async function getAccountCairoVersion( } return "0" - } catch (e) { + } catch { return undefined } } diff --git a/packages/extension/src/shared/utils/derivationPath.ts b/packages/extension/src/shared/utils/derivationPath.ts index c4a3d9098..dbf26533e 100644 --- a/packages/extension/src/shared/utils/derivationPath.ts +++ b/packages/extension/src/shared/utils/derivationPath.ts @@ -7,12 +7,19 @@ export function getPathForIndex( export function getIndexForPath(path: string, baseDerivationPath: string) { if (!path.startsWith(baseDerivationPath)) { - throw new Error("path should begin with baseDerivationPath") + throw new Error( + `path ${path} should begin with baseDerivationPath ${baseDerivationPath}`, + ) } const index = path.substring(path.lastIndexOf("/") + 1) return parseInt(index) } +export function getIndexForPathUnsafe(path: string) { + const index = path.substring(path.lastIndexOf("/") + 1) + return parseInt(index) +} + export function getNextPathIndex( paths: string[], baseDerivationPath: string, diff --git a/packages/extension/src/shared/utils/error.ts b/packages/extension/src/shared/utils/error.ts index 47500d2f5..958e0c08c 100644 --- a/packages/extension/src/shared/utils/error.ts +++ b/packages/extension/src/shared/utils/error.ts @@ -32,7 +32,7 @@ export const getErrorObject = ( }) return errorObject } - } catch (e) { + } catch { // ignore parsing error } } diff --git a/packages/extension/src/shared/utils/getActiveFromNow.ts b/packages/extension/src/shared/utils/getActiveFromNow.ts new file mode 100644 index 000000000..1321a63ce --- /dev/null +++ b/packages/extension/src/shared/utils/getActiveFromNow.ts @@ -0,0 +1,28 @@ +import { isNumeric, pluralise } from "@argent/x-shared" + +export const getActiveFromNow = (activeAt: number, now = new Date()) => { + if (!isNumeric(activeAt)) { + throw "activeAt should be numeric" + } + const activeFromNowMs = Math.max(0, activeAt * 1000 - now.getTime()) + /** 7 days max */ + const seconds = Math.floor((activeFromNowMs / 1000) % 60) + const minutes = Math.floor((activeFromNowMs / (1000 * 60)) % 60) + const hours = Math.floor((activeFromNowMs / (1000 * 60 * 60)) % 24) + const days = Math.floor(activeFromNowMs / (1000 * 60 * 60 * 24)) + const daysCeil = Math.ceil(activeFromNowMs / (1000 * 60 * 60 * 24)) + const activeFromNowPretty = + days > 0 + ? pluralise(daysCeil, "day") + : hours > 0 + ? pluralise(hours, "hour") + : minutes > 0 + ? pluralise(minutes, "minute") + : seconds > 0 + ? pluralise(seconds, "second") + : "now" + return { + activeFromNowMs, + activeFromNowPretty, + } +} diff --git a/packages/extension/src/shared/utils/getContractAddress.ts b/packages/extension/src/shared/utils/getContractAddress.ts index 1118f854b..102751000 100644 --- a/packages/extension/src/shared/utils/getContractAddress.ts +++ b/packages/extension/src/shared/utils/getContractAddress.ts @@ -1,14 +1,14 @@ import { addressSchema, getAccountContractAddress } from "@argent/x-shared" -import { memoize } from "lodash-es" +import memoize from "memoizee" export const getCairo1AccountContractAddress = memoize( (classHash: string, publicKey: string) => addressSchema.parse(getAccountContractAddress("1", classHash, publicKey)), - (classHash, publicKey) => `${classHash}:${publicKey}`, + { primitive: true }, ) export const getCairo0AccountContractAddress = memoize( (classHash: string, publicKey: string) => addressSchema.parse(getAccountContractAddress("0", classHash, publicKey)), - (classHash, publicKey) => `${classHash}:${publicKey}`, + { primitive: true }, ) diff --git a/packages/extension/src/shared/utils/isExternalAccount.ts b/packages/extension/src/shared/utils/isExternalAccount.ts new file mode 100644 index 000000000..555d654d3 --- /dev/null +++ b/packages/extension/src/shared/utils/isExternalAccount.ts @@ -0,0 +1,53 @@ +import type { CairoVersion } from "starknet" +import type { + ArgentWalletAccount, + ExternalWalletAccount, + WalletAccount, +} from "../wallet.model" +import { + argentAccountTypeSchema, + externalAccountTypeSchema, +} from "../wallet.model" +import { getArgentAccountClassHashes, isEqualAddress } from "@argent/x-shared" + +export function isArgentAccount( + account: WalletAccount, +): account is ArgentWalletAccount { + return argentAccountTypeSchema.safeParse(account.type).success +} + +export function isExternalAccount( + account: WalletAccount, +): account is ExternalWalletAccount { + return externalAccountTypeSchema.safeParse(account.type).success +} + +export const walletAccountToArgentAccount = ( + account: WalletAccount, +): ArgentWalletAccount => { + if (isArgentAccount(account)) { + return account + } + throw new Error("Not an argent account") +} + +export const filterArgentAccounts = (accounts: WalletAccount[]) => + accounts.filter(isArgentAccount) + +export const isArgentAccountClassHash = ( + classHash?: string, + cairoVersion: CairoVersion = "1", +) => { + const argentClassHashes = getArgentAccountClassHashes( + cairoVersion === "0" ? "cairo0" : "cairo1", + ) + + return argentClassHashes.some((hash) => isEqualAddress(hash, classHash)) +} + +export const isImportedArgentAccount = (account: WalletAccount) => { + return ( + account.type === "imported" && + isArgentAccountClassHash(account.classHash, account.cairoVersion) + ) +} diff --git a/packages/extension/src/shared/utils/isSafeUpgradeTransaction.test.ts b/packages/extension/src/shared/utils/isSafeUpgradeTransaction.test.ts index 5a36276af..c4c3c4d4e 100644 --- a/packages/extension/src/shared/utils/isSafeUpgradeTransaction.test.ts +++ b/packages/extension/src/shared/utils/isSafeUpgradeTransaction.test.ts @@ -1,14 +1,26 @@ // Import the method and any other dependencies import { describe, it, expect } from "vitest" import { isSafeUpgradeTransaction } from "./isSafeUpgradeTransaction" -import { ExtendedTransactionType, Transaction } from "../transactions" +import type { ExtendedTransactionType, Transaction } from "../transactions" import { SignerType } from "../wallet.model" -import { Address } from "@argent/x-shared" +import type { Address } from "@argent/x-shared" +import { getAccountIdentifier } from "./accountIdentifier" + +const address = + "0x07059f14f63fe428f802520078965ead76fbe8693d1f7bd88de74a887cccf418" +const networkId = "sepolia-alpha" + +const mockSigner = { + type: SignerType.LOCAL_SECRET, + derivationPath: "m/44'/60'/0'/0/0", +} + +const accountId = getAccountIdentifier(address, networkId, mockSigner) const transactionWithMeta: Transaction = { account: { - address: - "0x07059f14f63fe428f802520078965ead76fbe8693d1f7bd88de74a887cccf418", + id: accountId, + address, cairoVersion: "1", classHash: "0x0737ee2f87ce571a58c6c8da558ec18a07ceb64a6172d5ec46171fbc80077a48", @@ -27,7 +39,7 @@ const transactionWithMeta: Transaction = { }, chainId: "SN_SEPOLIA", explorerUrl: "https://sepolia.voyager.online", - id: "sepolia-alpha", + id: networkId, l1ExplorerUrl: "https://sepolia.etherscan.io", multicallAddress: "0x05754af3760f3356da99aea5c3ec39ccac7783d925a19666ebbeca58ff0087f4", @@ -40,10 +52,7 @@ const transactionWithMeta: Transaction = { rpcUrl: "https://api.hydrogen.argent47.net/v1/starknet/sepolia/rpc/v0.7", }, networkId: "sepolia-alpha", - signer: { - derivationPath: "m/44'/9004'/1'/0/8", - type: SignerType.LOCAL_SECRET, - }, + signer: mockSigner, type: "multisig", }, hash: "0x07b1cd0fa0ebf7421a967e435e55d0195d0c89db1bca7acc0ed551d76d320534", diff --git a/packages/extension/src/shared/utils/isSafeUpgradeTransaction.ts b/packages/extension/src/shared/utils/isSafeUpgradeTransaction.ts index 2c34a39a3..dc17354af 100644 --- a/packages/extension/src/shared/utils/isSafeUpgradeTransaction.ts +++ b/packages/extension/src/shared/utils/isSafeUpgradeTransaction.ts @@ -1,8 +1,8 @@ import { addressSchema } from "@argent/x-shared" -import { TransactionActionPayload } from "../actionQueue/types" +import type { TransactionActionPayload } from "../actionQueue/types" import { transformTransaction } from "../activity/utils/transform" import { isUpgradeTransaction } from "../activity/utils/transform/is" -import { Transaction } from "../transactions" +import type { Transaction } from "../transactions" // This function checks if a transaction is a safe upgrade transaction // with backwards compatibility for old upgrade transactions diff --git a/packages/extension/src/shared/utils/object.ts b/packages/extension/src/shared/utils/object.ts index 532ec9dc7..29285e52e 100644 --- a/packages/extension/src/shared/utils/object.ts +++ b/packages/extension/src/shared/utils/object.ts @@ -6,8 +6,8 @@ export const isEmptyValue = (value: any) => { const result = isString(value) ? value === "" : isObject(value) - ? Object.keys(value).length === 0 - : value === undefined + ? Object.keys(value).length === 0 + : value === undefined return result } diff --git a/packages/extension/src/shared/utils/sanitizeAccountType.test.ts b/packages/extension/src/shared/utils/sanitizeAccountType.test.ts index 5cb660801..5c1b2c099 100644 --- a/packages/extension/src/shared/utils/sanitizeAccountType.test.ts +++ b/packages/extension/src/shared/utils/sanitizeAccountType.test.ts @@ -1,5 +1,5 @@ import { sanitizeAccountType } from "./sanitizeAccountType" -import { ArgentAccountType } from "../wallet.model" +import type { ArgentAccountType } from "../wallet.model" describe("sanitizeAccountType", () => { test.each(["standard", "multisig", "smart"])( diff --git a/packages/extension/src/shared/utils/sanitizeAccountType.ts b/packages/extension/src/shared/utils/sanitizeAccountType.ts index 518cc138f..01ec5bd30 100644 --- a/packages/extension/src/shared/utils/sanitizeAccountType.ts +++ b/packages/extension/src/shared/utils/sanitizeAccountType.ts @@ -1,9 +1,9 @@ -import { ArgentAccountType } from "../wallet.model" +import type { WalletAccountType } from "../wallet.model" type SanitizedAccountType = "standard" | "smart" | "multisig" export const sanitizeAccountType = ( - type: ArgentAccountType | undefined, + type: WalletAccountType | undefined, ): SanitizedAccountType => { if (!type) { return "standard" diff --git a/packages/extension/src/shared/utils/starknetNetwork.ts b/packages/extension/src/shared/utils/starknetNetwork.ts index 2fe412ed9..f07a6d190 100644 --- a/packages/extension/src/shared/utils/starknetNetwork.ts +++ b/packages/extension/src/shared/utils/starknetNetwork.ts @@ -1,6 +1,6 @@ import { constants } from "starknet" -import { Network } from "../network" +import type { Network } from "../network" /** * NOTE: Sepolia - Currently Multisig only distinguishes between 'testnet' and 'mainnet' diff --git a/packages/extension/src/shared/utils/transactionSucceeded.ts b/packages/extension/src/shared/utils/transactionSucceeded.ts index b6b343fff..513575b5d 100644 --- a/packages/extension/src/shared/utils/transactionSucceeded.ts +++ b/packages/extension/src/shared/utils/transactionSucceeded.ts @@ -1,4 +1,4 @@ -import { Transaction } from "../transactions" +import type { Transaction } from "../transactions" import { getTransactionStatus } from "../transactions/utils" export const hasSuccessfulTransaction = ( diff --git a/packages/extension/src/shared/utils/transactions.ts b/packages/extension/src/shared/utils/transactions.ts index ee1f680c3..59aa644c0 100644 --- a/packages/extension/src/shared/utils/transactions.ts +++ b/packages/extension/src/shared/utils/transactions.ts @@ -1,5 +1,5 @@ import { camelCase, snakeCase } from "lodash-es" -import { CairoVersion } from "starknet" +import type { CairoVersion } from "starknet" export const getEntryPointSafe = ( entryPoint: string, diff --git a/packages/extension/src/shared/utils/url.test.ts b/packages/extension/src/shared/utils/url.test.ts new file mode 100644 index 000000000..a717961be --- /dev/null +++ b/packages/extension/src/shared/utils/url.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, test } from "vitest" + +import { addQueryToUrl, getBaseUrlForHost, urlWithQuery } from "./url" + +describe("url", () => { + describe("getBaseUrlForHost", () => { + describe("when valid", () => { + test("should return the base url", () => { + expect(getBaseUrlForHost("foo.bar.xyz")).toEqual("https://foo.bar.xyz") + expect(getBaseUrlForHost("foo.bar.xyz/foo/bar/")).toEqual( + "https://foo.bar.xyz", + ) + }) + }) + describe("when invalid", () => { + test("should throw an error", () => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + expect(() => getBaseUrlForHost({})).toThrowErrorMatchingInlineSnapshot( + '"Unable to make a base url from host"', + ) + }) + }) + }) + + describe("urlWithQuery", () => { + describe("when valid", () => { + test("should return a url with query", () => { + expect(urlWithQuery("https://foo.bar.xyz", { foo: "bar" })).toEqual( + "https://foo.bar.xyz/?foo=bar", + ) + expect( + urlWithQuery("https://foo.bar.xyz", { foo: "bar baz", bar: "foo" }), + ).toEqual("https://foo.bar.xyz/?foo=bar+baz&bar=foo") + expect( + urlWithQuery(["https://foo.bar.xyz", "baz", "qux"], { foo: "bar" }), + ).toEqual("https://foo.bar.xyz/baz/qux?foo=bar") + expect( + urlWithQuery(["/foo.bar.xyz", "baz", "qux"], { + foo: "bar baz", + bar: "foo", + }), + ).toEqual("/foo.bar.xyz/baz/qux?foo=bar+baz&bar=foo") + expect( + urlWithQuery("/account/activity?restoreScrollState=true", { + restoreScrollState: "true", + }), + ).toEqual("/account/activity?restoreScrollState=true") + }) + }) + describe("when invalid", () => { + test("should throw an error", () => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + expect(() => urlWithQuery({})).toThrow(TypeError) + }) + }) + }) + + describe("addQuery", () => { + describe("when valid", () => { + test("should return a url with query", () => { + expect(addQueryToUrl("https://foo.bar.xyz", { foo: "bar" })).toEqual( + "https://foo.bar.xyz/?foo=bar", + ) + expect( + addQueryToUrl("https://foo.bar.xyz?foo=bar+baz", { bar: "foo" }), + ).toEqual("https://foo.bar.xyz/?foo=bar+baz&bar=foo") + expect( + addQueryToUrl("https://foo.bar.xyz?foo=bar+baz", { foo: "bar" }), + ).toEqual("https://foo.bar.xyz/?foo=bar") + expect( + addQueryToUrl("/foo.bar.xyz/baz/qux?bar=baz", { + foo: "bar baz", + bar: "foo", + }), + ).toEqual("/foo.bar.xyz/baz/qux?bar=foo&foo=bar+baz") + expect( + addQueryToUrl("/account/activity?restoreScrollState=true", { + restoreScrollState: "true", + }), + ).toEqual("/account/activity?restoreScrollState=true") + }) + }) + describe("when invalid", () => { + test("should throw an error", () => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + expect(() => addQueryToUrl({})).toThrow(TypeError) + }) + }) + }) +}) diff --git a/packages/extension/src/shared/utils/url.ts b/packages/extension/src/shared/utils/url.ts index 5a73b1841..8611b3d96 100644 --- a/packages/extension/src/shared/utils/url.ts +++ b/packages/extension/src/shared/utils/url.ts @@ -4,13 +4,13 @@ export const getBaseUrlForHost = (host: string) => { try { const url = new URL(host) return url.origin - } catch (e) { + } catch { // host is not a valid URL } try { const url = new URL(`https://${host}`) return url.origin - } catch (e) { + } catch { // adding a protocol didn't make a valid URL } throw "Unable to make a base url from host" @@ -20,9 +20,32 @@ export const urlWithQuery = ( url: string | string[], query: Record, ) => { - const searchParams = new URLSearchParams(query) - const urlWithQuery = Array.isArray(url) - ? urlJoin(...url, `?${searchParams}`) - : urlJoin(url, `?${searchParams}`) - return urlWithQuery + return Array.isArray(url) + ? addQueryToUrl(urlJoin(url), query) + : addQueryToUrl(url, query) +} + +export const addQueryToUrl = (url: string, query: Record) => { + let urlObj: URL + let isTemporaryBase = false + + try { + // Try to create a URL object directly + urlObj = new URL(url) + } catch { + // If it fails, assume it's a pathname and create a temporary base URL + urlObj = new URL(url, "https://example.com") + isTemporaryBase = true + } + + Object.entries(query).forEach(([key, value]) => { + urlObj.searchParams.set(key, value) + }) + + // If we used the temporary base, return only the pathname and search params + if (isTemporaryBase) { + return `${urlObj.pathname}${urlObj.search}` + } + + return urlObj.toString() } diff --git a/packages/extension/src/shared/utils/validateSignatureChainId.test.ts b/packages/extension/src/shared/utils/validateSignatureChainId.test.ts new file mode 100644 index 000000000..659cfe868 --- /dev/null +++ b/packages/extension/src/shared/utils/validateSignatureChainId.test.ts @@ -0,0 +1,53 @@ +import type { TypedData } from "starknet" +import { validateSignatureChainId } from "./validateSignatureChainId" +import { getMockWalletAccount } from "../../../test/walletAccount.mock" +import { getMockNetwork } from "../../../test/network.mock" + +describe("validateSignatureChainId", () => { + const selectedAccount = getMockWalletAccount({ + network: { + ...getMockNetwork({ chainId: "SN_MAIN" }), + }, + }) + + test("should return success true when chainIds match", () => { + const typedData = { + domain: { + chainId: "SN_MAIN", + }, + } as unknown as TypedData + + const result = validateSignatureChainId(selectedAccount, typedData) + expect(result).toEqual({ success: true }) + }) + + test("should return success false when chainIds do not match", () => { + const typedData = { + domain: { + chainId: "SN_SEPOLIA", + }, + } as unknown as TypedData + + const result = validateSignatureChainId(selectedAccount, typedData) + expect(result).toEqual({ + success: false, + error: + "Cannot sign the message from a different chainId. Expected 0x534e5f4d41494e, got 0x534e5f5345504f4c4941", + }) + }) + + test("should return success true when typedData.domain.chainId is undefined", () => { + const typedData = { + domain: { + // chainId omitted + }, + // other properties omitted for brevity + } as unknown as TypedData + + const result = validateSignatureChainId(selectedAccount, typedData) + expect(result).toEqual({ + success: false, + error: "Cannot sign the message without chainId", + }) + }) +}) diff --git a/packages/extension/src/shared/utils/validateSignatureChainId.ts b/packages/extension/src/shared/utils/validateSignatureChainId.ts new file mode 100644 index 000000000..282dd294a --- /dev/null +++ b/packages/extension/src/shared/utils/validateSignatureChainId.ts @@ -0,0 +1,34 @@ +import type { TypedData } from "starknet" +import type { WalletAccount } from "../wallet.model" +import { encodeChainId } from "./encodeChainId" + +type ValidateSignatureChainIdResult = + | { + success: false + error: string + } + | { + success: true + } + +export const validateSignatureChainId = ( + selectedAccount: WalletAccount, + typedData: TypedData, +): ValidateSignatureChainIdResult => { + // let's compare encoded formats of both chainIds + const encodedDomainChainId = encodeChainId(typedData.domain.chainId) + const encodedSelectedChainId = encodeChainId(selectedAccount.network.chainId) + + if (!encodedDomainChainId) { + return { success: false, error: "Cannot sign the message without chainId" } + } + + if (encodedSelectedChainId !== encodedDomainChainId) { + return { + success: false, + error: `Cannot sign the message from a different chainId. Expected ${encodedSelectedChainId}, got ${encodedDomainChainId}`, + } + } + + return { success: true } +} diff --git a/packages/extension/src/shared/wallet.model.ts b/packages/extension/src/shared/wallet.model.ts index f48e9708e..8112f4d64 100644 --- a/packages/extension/src/shared/wallet.model.ts +++ b/packages/extension/src/shared/wallet.model.ts @@ -13,18 +13,31 @@ export const argentAccountTypeSchema = z.enum([ "standardCairo0", "smart", ]) + export const createAccountTypeSchema = argentAccountTypeSchema.exclude([ "plugin", "betterMulticall", "argent5MinuteEscapeTestingAccount", ]) +export const externalAccountTypeSchema = z.enum(["imported"]) + +export const walletAccountTypeSchema = argentAccountTypeSchema.or( + externalAccountTypeSchema, +) + +export const accountIdSchema = z + .string() + .regex(/^0x[a-fA-F0-9]+::[a-zA-Z0-9_-]+::[a-zA-Z0-9_]+::[0-9]+$/) + export const baseWalletAccountSchema = z.object({ + id: accountIdSchema, address: z.string(), networkId: z.string(), }) export const networkOnlyPlaceholderAccountSchema = z.object({ + id: z.null(), address: z.null(), networkId: z.string(), }) @@ -32,6 +45,7 @@ export const networkOnlyPlaceholderAccountSchema = z.object({ export enum SignerType { LOCAL_SECRET = "local_secret", LEDGER = "ledger", + PRIVATE_KEY = "private_key", } export const signerTypeSchema = z.nativeEnum(SignerType) @@ -53,11 +67,12 @@ export const withLedgerSignerSchema = z.object({ }) export const cairoVersionSchema = z.union([z.literal("0"), z.literal("1")]) + export const walletAccountSchema = z .object({ name: z.string(), network: networkSchema, - type: argentAccountTypeSchema, + type: walletAccountTypeSchema, classHash: addressSchema.optional(), cairoVersion: cairoVersionSchema.optional(), hidden: z.boolean().optional(), @@ -72,6 +87,18 @@ export const walletAccountSchema = z .merge(withSignerSchema) .merge(baseWalletAccountSchema) +export const argentWalletAccountSchema = walletAccountSchema.extend({ + type: argentAccountTypeSchema, +}) + +export const createWalletAccountSchema = walletAccountSchema.extend({ + type: createAccountTypeSchema, +}) + +export const externalWalletAccountSchema = walletAccountSchema.extend({ + type: externalAccountTypeSchema, +}) + export const storedWalletAccountSchema = walletAccountSchema.omit({ network: true, }) @@ -82,23 +109,21 @@ export const multisigDataSchema = z.object({ threshold: z.number(), creator: z.string().optional(), // Creator is the public key of the account that created the multisig account updatedAt: z.number(), + derivationPath: z.string().optional(), index: z.number().optional(), + pendingSigner: z // Pending signer is the signer that will replace the current signer when the replace signer transaction is confirmed + .object({ signer: walletAccountSignerSchema, pubKey: z.string() }) + .optional(), }) export const baseMultisigWalletAccountSchema = baseWalletAccountSchema.merge(multisigDataSchema) -export const multisigWalletAccountSchema = z - .object({ - type: z.literal("multisig"), - }) - .merge(walletAccountSchema) - .merge(multisigDataSchema) -export const createWalletAccountSchema = z - .object({ - type: createAccountTypeSchema, +export const multisigWalletAccountSchema = walletAccountSchema + .merge(multisigDataSchema) + .extend({ + type: z.literal("multisig"), }) - .merge(walletAccountSchema) export const recoveredLedgerMultisigSchema = z.object({ pubKey: z.string(), @@ -109,6 +134,7 @@ export const importedLedgerAccountSchema = baseWalletAccountSchema.merge( withLedgerSignerSchema, ) +export type AccountId = z.infer export type StoredWalletAccount = z.infer export type MultisigData = z.infer export type MultisigWalletAccount = z.infer @@ -123,7 +149,11 @@ export type NetworkOnlyPlaceholderAccount = z.infer< export type WalletAccountSigner = z.infer export type ArgentAccountType = z.infer export type CreateAccountType = z.infer +export type ExternalAccountType = z.infer +export type WalletAccountType = z.infer export type WalletAccount = z.infer +export type ArgentWalletAccount = z.infer +export type ExternalWalletAccount = z.infer export type CreateWalletAccount = z.infer export type RecoveredLedgerMultisig = z.infer< typeof recoveredLedgerMultisigSchema @@ -138,6 +168,7 @@ export function isNetworkOnlyPlaceholderAccount( export const defaultNetworkOnlyPlaceholderAccount: NetworkOnlyPlaceholderAccount = { + id: null, address: null, networkId: defaultNetwork.id, } diff --git a/packages/extension/src/shared/wallet.service.ts b/packages/extension/src/shared/wallet.service.ts index febfb9343..40255cea2 100644 --- a/packages/extension/src/shared/wallet.service.ts +++ b/packages/extension/src/shared/wallet.service.ts @@ -1,5 +1,9 @@ import { isEqualAddress } from "@argent/x-shared" -import { BaseWalletAccount, SignerType, WalletAccount } from "./wallet.model" +import type { + BaseWalletAccount, + SignerType, + WalletAccount, +} from "./wallet.model" import { getBaseDerivationPath } from "./signer/utils" export const DEPRECATED_TX_V0_ACCOUNT_IMPLEMENTATION_CLASS_HASH = [ @@ -68,9 +72,6 @@ export const isEqualWalletAddress = ( export const isAccountHidden = (account: Pick) => account.hidden === true -export const getAccountIdentifier = (account: BaseWalletAccount) => - `${account.networkId}::${account.address}` - export const getPendingMultisigIdentifier = (pendingMultisig: { networkId: string publicKey: string diff --git a/packages/extension/src/shared/wallet/getDefaultSortedAccount.ts b/packages/extension/src/shared/wallet/getDefaultSortedAccount.ts index 5195047e4..f87f53d90 100644 --- a/packages/extension/src/shared/wallet/getDefaultSortedAccount.ts +++ b/packages/extension/src/shared/wallet/getDefaultSortedAccount.ts @@ -1,4 +1,4 @@ -import { WalletAccount } from "../wallet.model" +import type { WalletAccount } from "../wallet.model" export const getDefaultSortedAccounts = (accounts: WalletAccount[]) => { return accounts.sort((a, b) => { diff --git a/packages/extension/src/shared/wallet/getDefaultSortedAccounts.test.ts b/packages/extension/src/shared/wallet/getDefaultSortedAccounts.test.ts index 1b74d39b9..4c118dae0 100644 --- a/packages/extension/src/shared/wallet/getDefaultSortedAccounts.test.ts +++ b/packages/extension/src/shared/wallet/getDefaultSortedAccounts.test.ts @@ -1,4 +1,4 @@ -import { WalletAccount } from "../wallet.model" +import type { WalletAccount } from "../wallet.model" import { getDefaultSortedAccounts } from "./getDefaultSortedAccount" describe("getDefaultSortedAccounts", () => { diff --git a/packages/extension/src/shared/wallet/walletStore.ts b/packages/extension/src/shared/wallet/walletStore.ts index e74c4d293..955c6d06d 100644 --- a/packages/extension/src/shared/wallet/walletStore.ts +++ b/packages/extension/src/shared/wallet/walletStore.ts @@ -1,14 +1,19 @@ import { KeyValueStorage } from "../storage" -import { IObjectStore } from "../storage/__new/interface" +import type { IObjectStore } from "../storage/__new/interface" import { adaptKeyValue } from "../storage/__new/keyvalue" -import { +import type { BaseWalletAccount, NetworkOnlyPlaceholderAccount, } from "../wallet.model" +export type SelectedWalletStoreAccount = + | BaseWalletAccount + | NetworkOnlyPlaceholderAccount + | null + export interface WalletStorageProps { backup?: string - selected?: BaseWalletAccount | NetworkOnlyPlaceholderAccount | null + selected?: SelectedWalletStoreAccount discoveredOnce?: boolean hasSavedRecoverySeedPhrase?: boolean lastUsedAccountByNetwork?: Record diff --git a/packages/extension/src/ui/App.tsx b/packages/extension/src/ui/App.tsx index faf9f4e92..b8ec6f335 100644 --- a/packages/extension/src/ui/App.tsx +++ b/packages/extension/src/ui/App.tsx @@ -1,8 +1,7 @@ -import { EventEmitterProvider } from "@argent/x-shared" -import { SetDarkMode } from "@argent/x-ui" -import { ThemeProvider as MuiThemeProvider } from "@mui/material" +import { EventEmitterProvider, SetDarkMode } from "@argent/x-ui" import Emittery from "emittery" -import { FC, useRef } from "react" +import type { FC } from "react" +import { useRef } from "react" import { SWRConfig } from "swr" import { MotionConfig } from "framer-motion" import { BrowserRouter } from "react-router-dom" @@ -13,7 +12,6 @@ import { ErrorBoundary } from "./components/ErrorBoundary" import { AppDimensions } from "./components/Responsive" import SoftReloadProvider from "./services/resetAndReload" import { onErrorRetry, swrCacheProvider } from "./services/swr.service" -import { muiTheme } from "./theme" import { useAnalytics } from "./hooks/useAnalytics" import { ArgentUIProviders } from "./providers/ArgentUIProviders" import { useGlobalUtilityMethods } from "./hooks/useGlobalUtilityMethods" @@ -40,43 +38,35 @@ export const App: FC = () => { onErrorRetry: onErrorRetry, }} > - - - - - - - - - - - - - - - - {DevUI && } - - - }> - - - - - - - - - - - + + + + + + + + + + + + {DevUI && } + + + }> + + + + + + + + + + ) diff --git a/packages/extension/src/ui/AppBackgroundError.tsx b/packages/extension/src/ui/AppBackgroundError.tsx index 14fa3570a..43309454f 100644 --- a/packages/extension/src/ui/AppBackgroundError.tsx +++ b/packages/extension/src/ui/AppBackgroundError.tsx @@ -1,9 +1,9 @@ import { Button, Center, Flex, useDisclosure } from "@chakra-ui/react" -import { FC } from "react" +import type { FC } from "react" -import { H4, P3 } from "@argent/x-ui" +import { H3, P2 } from "@argent/x-ui" import { SupportFooter } from "./features/settings/ui/SupportFooter" -import { useClearLocalStorage } from "./features/settings/developerSettings/clearLocalStorage/useClearLocalStorage" +import { useClearLocalStorage } from "./features/settings/advanced/clearLocalStorage/useClearLocalStorage" import { useHardResetAndReload } from "./services/resetAndReload" import { ClearStorageModal } from "./components/ClearStorageModal" @@ -25,12 +25,12 @@ export const AppBackgroundError: FC = () => { return (
-

Argent X can’t start

- +

Argent X can’t start

+ Sorry, an error occurred while starting the Argent X background process. Accounts are not affected. Please contact support for further instructions. -
+ diff --git a/packages/extension/src/ui/AppErrorBoundaryFallback.tsx b/packages/extension/src/ui/AppErrorBoundaryFallback.tsx index 8c3dcaa77..7e0410c71 100644 --- a/packages/extension/src/ui/AppErrorBoundaryFallback.tsx +++ b/packages/extension/src/ui/AppErrorBoundaryFallback.tsx @@ -1,19 +1,24 @@ -import { Flex } from "@chakra-ui/react" -import { FC } from "react" +import type { FC } from "react" -import { ErrorBoundaryState } from "./components/ErrorBoundary" -import ErrorBoundaryFallbackWithCopyError from "./components/ErrorBoundaryFallbackWithCopyError" +import type { ErrorBoundaryState } from "./components/ErrorBoundary" +import { ErrorBoundaryFallbackWithCopyError } from "./components/ErrorBoundaryFallbackWithCopyError" import { SupportFooter } from "./features/settings/ui/SupportFooter" +import { ScrollContainer } from "@argent/x-ui" const AppErrorBoundaryFallback: FC = ({ error, errorInfo, }) => { return ( - - + + - + ) } diff --git a/packages/extension/src/ui/AppRoutes.tsx b/packages/extension/src/ui/AppRoutes.tsx index a962f44b4..c440781c0 100644 --- a/packages/extension/src/ui/AppRoutes.tsx +++ b/packages/extension/src/ui/AppRoutes.tsx @@ -1,28 +1,30 @@ -import { Route, Routes, RoutesConfig } from "@argent/stack-router" -import { chakra } from "@chakra-ui/react" -import { FC, ReactNode, isValidElement, useEffect, useMemo } from "react" -// import { Outlet, Route, Routes } from "react-router-dom" // reinstate in case of issues with @argent/stack-router -import { Location, Navigate, Outlet, useLocation } from "react-router-dom" +import type { FC, ReactNode } from "react" +import { Suspense, isValidElement, useEffect, useMemo } from "react" +import type { Location } from "react-router-dom" +import { Navigate, Outlet, useLocation } from "react-router-dom" + +import { Route, Routes, RoutesConfig } from "./router" -import { isActivityV2FeatureEnabled } from "../shared/activity" import { routes } from "../shared/ui/routes" import { useMessageStreamHandler } from "./hooks/useMessageStreamHandler" import { AppBackgroundError } from "./AppBackgroundError" -import { ResponsiveBox } from "./components/Responsive" +import { ResponsiveAppContainer } from "./components/Responsive" import { SuspenseScreen } from "./components/SuspenseScreen" -import { TransactionDetailScreen } from "./features/accountActivity/TransactionDetailScreen" -import { ActivityDetailsScreenContainer } from "./features/accountActivityV2/ActivityDetailsScreenContainer" +import { ActivityDetailsScreenContainer } from "./features/accountActivity/ActivityDetailsScreenContainer" import { CollectionNftsContainer } from "./features/accountNfts/CollectionNftsContainer" import { NftScreenContainer } from "./features/accountNfts/NftScreenContainer" import { AccountListHiddenScreenContainer } from "./features/accounts/AccountListHiddenScreenContainer" -import { AccountListScreenContainer } from "./features/accounts/AccountListScreenContainer" +import { + AccountListScreenContainer, + AccountListScreenContainerSkeleton, +} from "./features/accounts/AccountListScreenContainer" import { RootTabsScreenContainer } from "./features/root/RootTabsScreenContainer" import { AddNewAccountScreenContainer } from "./features/accounts/AddNewAccountScreenContainer" import { HideOrDeleteAccountConfirmScreenContainer } from "./features/accounts/HideOrDeleteAccountConfirmScreenContainer" import { HideTokenScreenContainer } from "./features/accountTokens/HideTokenScreenContainer" import { AccountDeprecatedModal } from "./features/accountTokens/warning/AccountDeprecatedModal" import { AccountOwnerWarningScreen } from "./features/accountTokens/warning/AccountOwnerWarningScreen" -import { ActionScreenContainer } from "./features/actions/ActionScreen" +import { ActionScreenContainer } from "./features/actions/ActionScreenContainer" import { AddTokenScreenContainer } from "./features/actions/AddTokenScreenContainer" import { ErrorScreenContainer } from "./features/actions/ErrorScreenContainer" import { LoadingScreenContainer } from "./features/actions/LoadingScreenContainer" @@ -59,8 +61,8 @@ import { OnboardingPrivacyScreenContainer } from "./features/onboarding/Onboardi import { OnboardingRestoreBackupScreenContainer } from "./features/onboarding/OnboardingRestoreBackupScreenContainer" import { OnboardingRestorePasswordScreenContainer } from "./features/onboarding/OnboardingRestorePasswordScreenContainer" import { OnboardingRestoreSeedScreenContainer } from "./features/onboarding/OnboardingRestoreSeedScreenContainer" -import OnboardingSmartAccountEmailScreen from "./features/onboarding/OnboardingSmartAccountEmailScreen" -import OnboardingSmartAccountOTPScreen from "./features/onboarding/OnboardingSmartAccountOTPScreen" +import { OnboardingSmartAccountEmailScreenContainer } from "./features/onboarding/OnboardingSmartAccountEmailScreenContainer" +import { OnboardingSmartAccountOTPScreenContainer } from "./features/onboarding/OnboardingSmartAccountOTPScreenContainer" import { OnboardingStartScreenContainer } from "./features/onboarding/OnboardingStartScreenContainer" import { RecoverySetupScreen } from "./features/recovery/RecoverySetupScreen" import { SeedRecoverySetupScreen } from "./features/recovery/SeedRecoverySetupScreen" @@ -76,26 +78,23 @@ import { AddressBookAddOrEditScreenContainer } from "./features/settings/address import { AddressBookSettingsScreenContainer } from "./features/settings/addressBook/AddressBookSettingsScreenContainer" import { DappConnectionsAccountListScreenContainer } from "./features/settings/connectedDapps/DappConnectionsAccountListScreenContainer" import { DappConnectionsAccountScreenContainer } from "./features/settings/connectedDapps/DappConnectionsAccountScreenContainer" -import { BetaFeaturesSettingsScreenContainer } from "./features/settings/developerSettings/betaFeatures/BetaFeaturesSettingsScreenContainer" -import { ClearLocalStorageScreen } from "./features/settings/developerSettings/clearLocalStorage/ClearLocalStorageScreen" -import { DeploymentDataScreen } from "./features/settings/developerSettings/deploymentData/DeploymentDataScreen" -import { DeveloperSettingsScreenContainer } from "./features/settings/developerSettings/DeveloperSettingsScreenContainer" -import { DownloadLogsScreen } from "./features/settings/developerSettings/downloadLogs/DownloadLogsScreen" -import { ExperimentalSettingsScreenContainer } from "./features/settings/developerSettings/experimental/ExperimentalSettingsScreenContainer" -import { NetworkSettingsEditScreen } from "./features/settings/developerSettings/manageNetworks/NetworkSettingsEditScreen" -import { NetworkSettingsFormScreenContainer } from "./features/settings/developerSettings/manageNetworks/NetworkSettingsFormScreenContainer" -import { NetworkSettingsScreenContainer } from "./features/settings/developerSettings/manageNetworks/NetworkSettingsScreenContainer" -import { DeclareOrDeployContractSuccessScreenContainer } from "./features/settings/developerSettings/smartContractDevelopment/DeclareOrDeployContractSuccessScreenContainer" -import { DeclareSmartContractScreen } from "./features/settings/developerSettings/smartContractDevelopment/DeclareSmartContractScreen" -import { DeploySmartContractScreen } from "./features/settings/developerSettings/smartContractDevelopment/DeploySmartContractScreen" -import { SmartContractDevelopmentScreen } from "./features/settings/developerSettings/smartContractDevelopment/SmartContractDevelopmentScreen" +import { BetaFeaturesSettingsScreenContainer } from "./features/settings/advanced/betaFeatures/BetaFeaturesSettingsScreenContainer" +import { ClearLocalStorageScreen } from "./features/settings/advanced/clearLocalStorage/ClearLocalStorageScreen" +import { DeploymentDataScreen } from "./features/settings/advanced/deploymentData/DeploymentDataScreen" +import { AdvancedSettingsScreenContainer } from "./features/settings/advanced/AdvancedSettingsScreenContainer" +import { DownloadLogsScreen } from "./features/settings/advanced/downloadLogs/DownloadLogsScreen" +import { ExperimentalSettingsScreenContainer } from "./features/settings/advanced/experimental/ExperimentalSettingsScreenContainer" +import { NetworkSettingsEditScreen } from "./features/settings/advanced/manageNetworks/NetworkSettingsEditScreen" +import { NetworkSettingsFormScreenContainer } from "./features/settings/advanced/manageNetworks/NetworkSettingsFormScreenContainer" +import { NetworkSettingsScreenContainer } from "./features/settings/advanced/manageNetworks/NetworkSettingsScreenContainer" import { BlockExplorerSettingsScreenContainer } from "./features/settings/preferences/BlockExplorerSettingsScreenContainer" import { NftMarketplaceSettingsScreenContainer } from "./features/settings/preferences/NftMarketplaceSettingsScreenContainer" import { PreferencesSettingsContainer } from "./features/settings/preferences/PreferencesSettingsContainer" -import { AutoLockTimerSettingsScreenContainer } from "./features/settings/securityAndPrivacy/AutoLockTimerSettingsScreenContainer" -import { BeforeYouContinueScreen } from "./features/settings/securityAndPrivacy/BeforeYouContinueScreen" -import { SecurityAndPrivacySettingsScreenContainer } from "./features/settings/securityAndPrivacy/SecurityAndPrivacySettingsScreenContainer" -import { SeedSettingsScreenContainer } from "./features/settings/securityAndPrivacy/SeedSettingsScreenContainer" +import { AutoLockTimerSettingsScreenContainer } from "./features/settings/securityAndRecovery/AutoLockTimerSettingsScreenContainer" +import { BeforeYouContinueScreen } from "./features/settings/securityAndRecovery/BeforeYouContinueScreen" +import { SecurityAndRecoverySettingsScreenContainer } from "./features/settings/securityAndRecovery/SecurityAndRecoverySettingsScreenContainer" +import { PrivacySettingsScreenContainer } from "./features/settings/privacy/PrivacySettingsScreenContainer" +import { SeedSettingsScreenContainer } from "./features/settings/securityAndRecovery/SeedSettingsScreenContainer" import { SettingsScreenContainer } from "./features/settings/SettingsScreenContainer" import { CreateSmartAccountEmailScreen } from "./features/smartAccount/CreateSmartAccountEmailScreen" import { CreateSmartAccountOTPScreen } from "./features/smartAccount/CreateSmartAccountOTPScreen" @@ -112,6 +111,7 @@ import { useClientUINavigate } from "./services/ui/useClientUINavigate" import { hasActionsView } from "./views/actions" import { useView } from "./views/implementation/react" import { isClearingStorageView, isRecoveringView } from "./views/recovery" +import { ImportPrivateKeyScreen } from "./features/importedAccounts/ImportPrivateKeyScreen" import { AirGapReviewScreen } from "./features/actions/transaction/airgap/AirGapReviewScreen" import { RouteWithLockScreen, @@ -120,6 +120,16 @@ import { import { useIsOnboardingComplete } from "./hooks/appState" import { routerService } from "./services/router" import { useClientUIShowNotification } from "./services/ui/useClientUIShowNotification" +import { TokenDetailsScreen } from "./features/tokenDetails/TokenDetailsScreen" +import { HiddenAndSpamTokensScreenContainer } from "./features/accountTokens/HiddenAndSpamTokensScreenContainer" +import { SwapScreenContainer } from "./features/swap/SwapScreenContainer" +import { StakingScreenContainer } from "./features/defi/staking/StakingScreenContainer" +import { NativeStakingScreenContainer } from "./features/defi/staking/NativeStakingScreenContainer" +import { NativeStakingProviderSelectScreenContainer } from "./features/defi/staking/NativeStakingProviderSelectScreenContainer" +import { UnstakingScreenContainer } from "./features/defi/staking/UnstakingScreenContainer" +import { DefiPositionDetailsScreenContainer } from "./features/defi/defiDecomposition/positionDetails/DefiPositionDetailsScreenContainer" +import { LiquidStakingProviderSelectScreenContainer } from "./features/defi/staking/LiquidStakingProviderSelectScreenContainer" +import { IdProviderSettingsScreenContainer } from "./features/settings/preferences/IdProviderSettingsScreenContainer" interface LocationWithState extends Location { state: { @@ -127,26 +137,10 @@ interface LocationWithState extends Location { } } -const ResponsiveContainer = chakra(ResponsiveBox, { - baseStyle: { - display: "flex", - flexDirection: "column", - position: "relative", - height: "100vh", - overflowY: "hidden", - overscrollBehavior: "none", - msOverflowStyle: "none", - scrollbarWidth: "none", - "&::-webkit-scrollbar": { - display: "none", - }, - }, -}) - const ResponsiveRoutes: FC = () => ( - + - + ) // Routes which don't need an unlocked wallet @@ -199,11 +193,6 @@ const withLockScreenRoutes = ( path={routes.accountCollections.path} element={} /> - } - /> + }> - + } /> } /> + } + /> } + element={} /> } /> + } + /> } /> + } + /> } /> - - } - /> } + path={routes.settingsAdvanced.path} + element={} /> + } /> - } - /> - } - /> - } - /> } /> - {isActivityV2FeatureEnabled ? ( - - - - } - /> - ) : ( - } - /> - )} } + path={routes.transactionDetail.path} + element={ + + + + } /> } + path={routes.accountHideOrDeleteConfirm.path} + element={} /> } /> + + + + } + /> } /> + + + + } + /> + + + + } + /> } + element={ + + + + } /> } /> + } + /> + + {/* Staking */} + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + } + presentation="push" + /> + + {/* Defi */} + + + + } + presentation="push" + /> ) @@ -696,11 +769,11 @@ const fullscreenRoutes = ( /> } + element={} /> } + element={} /> } /> { if (showActions) { return ( - + - + ) } diff --git a/packages/extension/src/ui/components/ActionButton.tsx b/packages/extension/src/ui/components/ActionButton.tsx new file mode 100644 index 000000000..1c71aee21 --- /dev/null +++ b/packages/extension/src/ui/components/ActionButton.tsx @@ -0,0 +1,45 @@ +import { B2 } from "@argent/x-ui" +import type { ButtonProps } from "@chakra-ui/react" +import { Button, forwardRef } from "@chakra-ui/react" +import type { ReactElement } from "react" + +interface ActionButtonProps extends ButtonProps { + icon: ReactElement + label: string +} + +export const ActionButton = forwardRef( + ({ label, icon, colorScheme = "secondary", size, ...rest }, ref) => { + const color = colorScheme === "primary" ? "surface-default" : undefined + const diameter = size === "sm" ? 10 : 12 + const fontSize = size === "sm" ? "xl" : "2xl" + const top = size === "sm" ? 12 : 14 + return ( + + ) + }, +) + +ActionButton.displayName = "ActionButton" diff --git a/packages/extension/src/ui/components/AddressCopyButton.tsx b/packages/extension/src/ui/components/AddressCopyButton.tsx index 10b8c4b85..7b5ab5076 100644 --- a/packages/extension/src/ui/components/AddressCopyButton.tsx +++ b/packages/extension/src/ui/components/AddressCopyButton.tsx @@ -1,11 +1,11 @@ import { Button, CopyTooltip } from "@argent/x-ui" -import { FC } from "react" +import type { FC } from "react" +import { formatTruncatedAddress, normalizeAddress } from "@argent/x-shared" import { useNavigate } from "react-router-dom" import { routes } from "../../shared/ui/routes" +import { needsToSaveRecoverySeedphraseView } from "../views/account" import { useView } from "../views/implementation/react" -import { hasSavedRecoverySeedPhraseView } from "../views/account" -import { normalizeAddress, formatTruncatedAddress } from "@argent/x-shared" export interface AddressCopyButtonProps { address: string @@ -17,10 +17,13 @@ export const AddressCopyButton: FC = ({ }) => { const copyValue = normalizeAddress(address) const navigate = useNavigate() - const hasSavedRecoverySeedPhrase = useView(hasSavedRecoverySeedPhraseView) - const onClick = hasSavedRecoverySeedPhrase - ? undefined - : () => navigate(routes.beforeYouContinue()) + const needsToSaveRecoverySeedphrase = useView( + needsToSaveRecoverySeedphraseView, + ) + + const onClick = needsToSaveRecoverySeedphrase + ? () => navigate(routes.beforeYouContinue()) + : undefined return ( , "to"> { - to?: string -} - -export const BackLink: FC = ({ to, ...props }) => ( - -) diff --git a/packages/extension/src/ui/components/BottomSheet.tsx b/packages/extension/src/ui/components/BottomSheet.tsx deleted file mode 100644 index 11657ce25..000000000 --- a/packages/extension/src/ui/components/BottomSheet.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import { FC, useEffect, useState } from "react" -import styled from "styled-components" - -const BottomSheetBackground = styled.div<{ - open: boolean -}>` - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - z-index: 99; - transition: all 0.3s ease-in-out; - background-color: ${({ open }) => - open ? "rgba(0, 0, 0, 0.5)" : "transparent"}; - opacity: ${({ open }) => (open ? 1 : 0)}; - visibility: ${({ open }) => (open ? "visible" : "hidden")}; - pointer-events: ${({ open }) => (open ? "auto" : "none")}; -` - -const BottomSheetWrapper = styled.div<{ - open: boolean - disableAnimation: boolean - maxHeight?: string -}>` - position: fixed; - max-height: ${({ maxHeight }) => (maxHeight ? maxHeight : "94vh")}; - bottom: 0; - left: 0; - right: 0; - background-color: ${({ theme }) => theme.bg1}; - border-radius: 16px 16px 0 0; - border-top: 2px solid rgba(255, 255, 255, 0.1); - box-shadow: 0px 8px 24px rgba(0, 0, 0, 0.3); - padding: 0px; - z-index: 100; - transform: ${({ open }) => (open ? `translateY(0%)` : `translateY(100%)`)}; - transition: ${({ disableAnimation, open }) => { - if (disableAnimation) { - return `transform 0s` - } - return `transform 0.3s ${open ? "ease-out" : "ease-in"}` - }}; -` - -const useFirstRender = () => { - const [firstRender, setFirstRender] = useState(true) - useEffect(() => { - setFirstRender(false) - }, []) - return firstRender -} - -export interface CustomBottomSheetProps { - open: boolean - onClose?: () => void - backgroundClassName?: string - children?: React.ReactNode - maxHeight?: string -} - -export const CustomBottomSheet: FC = ({ - open, - onClose, - backgroundClassName, - maxHeight, - children, - ...props -}) => { - const isFirstRender = useFirstRender() - - return ( - <> - - - {children} - - - ) -} diff --git a/packages/extension/src/ui/components/Button.tsx b/packages/extension/src/ui/components/Button.tsx deleted file mode 100644 index 10596d734..000000000 --- a/packages/extension/src/ui/components/Button.tsx +++ /dev/null @@ -1,317 +0,0 @@ -import { - ButtonHTMLAttributes, - FC, - MouseEvent, - ReactNode, - useCallback, - useRef, - useState, -} from "react" -import styled, { css } from "styled-components" -import { DefaultTheme } from "styled-components" - -export type ButtonVariant = - | "default" - | "primary" - | "warn" - | "warn-high" - | "danger" - | "info" - | "transparent" - | "inverted" - | "neutrals800" - -export type ButtonSize = "default" | "xs" | "s" | "m" | "l" | "xl" - -interface IButton extends ButtonHTMLAttributes { - variant?: ButtonVariant - size?: ButtonSize -} - -/** TODO: move color tokens into theme */ - -export const getVariantColor = - ({ - theme, - hover = false, - disabled = false, - }: { - theme: DefaultTheme - hover?: boolean - disabled?: boolean - }) => - ({ variant }: { variant?: ButtonVariant }) => { - switch (variant) { - case "danger": - return hover - ? theme.button.danger.bg.hover - : disabled - ? theme.button.danger.bg.disabled - : theme.button.danger.bg.base - case "warn-high": - return hover - ? theme.button["warn-high"].bg.hover - : disabled - ? theme.button["warn-high"].bg.disabled - : theme.button["warn-high"].bg.base - case "warn": - return hover - ? theme.button.warn.bg.hover - : disabled - ? theme.button.warn.bg.disabled - : theme.button.warn.bg.base - case "info": - return hover - ? theme.button.info.bg.hover - : disabled - ? theme.button.info.bg.disabled - : theme.button.info.bg.base - case "transparent": - return hover - ? theme.button.transparent.bg.hover - : disabled - ? theme.button.transparent.bg.disabled - : theme.button.transparent.bg.base - case "neutrals800": - return hover - ? theme.button.neutrals800.bg.hover - : disabled - ? theme.button.neutrals800.bg.disabled - : theme.button.neutrals800.bg.base - } - return hover - ? theme.button.default.bg.hover - : disabled - ? theme.button.default.bg.disabled - : theme.button.default.bg.base - } - -export const getSizeStyle = (size: ButtonSize = "default") => { - switch (size) { - case "xs": - return css` - padding: 4px 8px; - font-size: 13px; - line-height: 1.2; - ` - case "s": - return css` - padding: 6px 12px; - font-size: 14px; - line-height: 1.2; - ` - } - return css` - padding: 13.5px; - font-size: 16px; - line-height: 21px; - width: 100%; - ` -} - -export function getButtonColor( - layer: "bg" | "fg", - state: "base" | "hover" | "disabled", -): (props: { theme: DefaultTheme; variant?: ButtonVariant }) => string { - return ({ theme, variant = "default" }) => { - const v = theme.button[variant] ?? theme.button.default - const l = (v as any)[layer] ?? theme.button.default[layer] - return (l as any)[state] ?? l.base - } -} - -const BaseButton = styled.button` - margin: 0; - padding: 13.5px; - font-weight: 600; - font-size: 16px; - line-height: 21px; - text-align: center; - - background-color: ${getButtonColor("bg", "base")}; - border-radius: ${({ theme }) => theme.button.radius}; - width: 100%; - outline: none; - border: none; - color: ${getButtonColor("fg", "base")}; - cursor: pointer; - width: 100%; - outline: none; - border: none; - transition: color 200ms ease-in-out, background-color 200ms ease-in-out, - transform 100ms ease-in-out; - - cursor: pointer; -` - -export const Button = styled(BaseButton)` - ${({ size }) => getSizeStyle(size)} - margin: 0; - font-weight: 600; - text-align: center; - - background-color: ${getButtonColor("bg", "base")}; - border-radius: 100px; - color: ${getButtonColor("fg", "base")}; - - &:hover { - background-color: ${({ theme }) => getVariantColor({ theme, hover: true })}; - } - - &:hover, - &:focus { - outline: 0; - background-color: ${getButtonColor("bg", "hover")}; - } - - &:disabled { - cursor: auto; - cursor: not-allowed; - color: ${getButtonColor("fg", "disabled")}; - background-color: ${getButtonColor("bg", "disabled")}; - } -` - -export const ButtonGroup = styled.div` - display: flex; - flex-direction: column; - gap: 12px; - width: 100%; -` - -export const ButtonGroupHorizontal = styled.div<{ - switchButtonOrder?: boolean - buttonGap?: string -}>` - display: flex; - flex-direction: ${({ switchButtonOrder = false }) => - switchButtonOrder ? "row-reverse" : "row"}; - gap: ${({ buttonGap }) => buttonGap ?? "12px"}; - width: 100%; -` - -export const ButtonGroupVertical = styled.div<{ - switchButtonOrder?: boolean -}>` - display: flex; - flex-direction: ${({ switchButtonOrder = false }) => - switchButtonOrder ? "column-reverse" : "column"}; - gap: 8px; - width: 100%; -` - -/** TODO: change to variant=transparent */ -export const ButtonTransparent = styled(BaseButton)` - background-color: transparent; - color: ${({ theme }) => theme.text1}; - - &:hover, - &:focus { - outline: 0; - } - - &:disabled { - cursor: auto; - cursor: not-allowed; - color: rgba(255, 255, 255, 0.5); - } -` - -export const ButtonOutline = styled(BaseButton)` - background-color: transparent; - color: ${({ theme }) => theme.text1}; - border: 0.5px solid #ffffff; - border-radius: 4px; - - &:hover, - &:focus { - outline: 0; - } -` - -/** TODO: rationalise variants */ - -export interface IIconButton extends IButton { - icon: ReactNode - clickedIcon: ReactNode - clickedTimeout?: number -} - -export const PressableButton = styled(Button)` - &:active { - transform: scale( - ${({ size }) => (size === "s" || size === "xs" ? 0.95 : 0.975)} - ); - } -` - -const IconButtonContent = styled.div` - flex-direction: row; - gap: 8px; - display: flex; - align-items: center; - justify-content: center; -` - -interface IClicked { - clicked: boolean -} - -const IconWrapper = styled.div` - position: relative; -` - -const IconContainer = styled.div` - opacity: ${({ clicked }) => (clicked ? 0 : 1)}; - transition: opacity 0.15s ease-in-out; -` - -const ClickedIconContainer = styled.div` - opacity: ${({ clicked }) => (clicked ? 1 : 0)}; - transition: opacity 0.15s ease-in-out; - position: absolute; - left: 0; - right: 0; - top: 0; - bottom: 0; - display: flex; - align-items: center; - justify-content: center; -` - -export const IconButton: FC = ({ - icon, - clickedIcon, - clickedTimeout = 1000, - onClick: onClickProp, - children, - ...rest -}) => { - const timeoutId = useRef | null>(null) - const [clicked, setClicked] = useState(false) - const clearClicked = useCallback(() => { - setClicked(false) - }, []) - const onClick = useCallback( - (e: MouseEvent) => { - timeoutId.current && clearTimeout(timeoutId.current) - timeoutId.current = setTimeout(clearClicked, clickedTimeout) - setClicked(true) - onClickProp && onClickProp(e) - }, - [clearClicked, clickedTimeout, onClickProp], - ) - return ( - - - - {icon} - - {clickedIcon} - - - {children} - - - ) -} diff --git a/packages/extension/src/ui/components/ClearStorageModal.tsx b/packages/extension/src/ui/components/ClearStorageModal.tsx index 4b43579a0..7920c387a 100644 --- a/packages/extension/src/ui/components/ClearStorageModal.tsx +++ b/packages/extension/src/ui/components/ClearStorageModal.tsx @@ -1,4 +1,4 @@ -import { H5 } from "@argent/x-ui" +import { H4 } from "@argent/x-ui" import { Button, Flex, @@ -8,7 +8,7 @@ import { ModalHeader, ModalOverlay, } from "@chakra-ui/react" -import { FC } from "react" +import type { FC } from "react" import { PasswordForm } from "../features/lock/PasswordForm" interface ClearStorageModalProps { @@ -30,9 +30,9 @@ export const ClearStorageModal: FC = ({ -
+

Enter your password to clear storage -

+
diff --git a/packages/extension/src/ui/components/Column.tsx b/packages/extension/src/ui/components/Column.tsx deleted file mode 100644 index d80ebe44b..000000000 --- a/packages/extension/src/ui/components/Column.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import styled from "styled-components" - -const Column = styled.div<{ gap?: string }>` - display: flex; - flex-direction: column; - justify-content: flex-start; - - gap: ${({ gap }) => gap}; -` -export const ColumnCenter = styled(Column)` - width: 100%; - align-items: center; -` - -export const AutoColumn = styled.div<{ - gap?: "sm" | "md" | "lg" | string - justify?: - | "stretch" - | "center" - | "start" - | "end" - | "flex-start" - | "flex-end" - | "space-between" -}>` - display: grid; - grid-auto-rows: auto; - grid-row-gap: ${({ gap }) => - (gap === "sm" && "8px") || - (gap === "md" && "12px") || - (gap === "lg" && "24px") || - gap}; - justify-items: ${({ justify }) => justify}; -` - -export default Column diff --git a/packages/extension/src/ui/components/ControlledInput.tsx b/packages/extension/src/ui/components/ControlledInput.tsx index 5e4b705a9..822f08412 100644 --- a/packages/extension/src/ui/components/ControlledInput.tsx +++ b/packages/extension/src/ui/components/ControlledInput.tsx @@ -1,7 +1,9 @@ import { FieldError } from "@argent/x-ui" -import { Input, InputProps } from "@chakra-ui/react" -import { FC } from "react" -import { Control, Controller, FieldPath, FieldValues } from "react-hook-form" +import type { InputProps } from "@chakra-ui/react" +import { Input } from "@chakra-ui/react" +import type { FC } from "react" +import type { Control, FieldPath, FieldValues } from "react-hook-form" +import { Controller } from "react-hook-form" import { useAutoFocusInputRef } from "../hooks/useAutoFocusInputRef" diff --git a/packages/extension/src/ui/components/ControlledPinInput.tsx b/packages/extension/src/ui/components/ControlledPinInput.tsx index cc726f768..f92729fac 100644 --- a/packages/extension/src/ui/components/ControlledPinInput.tsx +++ b/packages/extension/src/ui/components/ControlledPinInput.tsx @@ -1,6 +1,8 @@ -import { PinInput, PinInputProps } from "@chakra-ui/react" -import { FC } from "react" -import { Control, Controller, FieldPath, FieldValues } from "react-hook-form" +import type { PinInputProps } from "@chakra-ui/react" +import { PinInput } from "@chakra-ui/react" +import type { FC } from "react" +import type { Control, FieldPath, FieldValues } from "react-hook-form" +import { Controller } from "react-hook-form" interface ControlledPinInputProps extends PinInputProps { diff --git a/packages/extension/src/ui/components/ControlledTextArea.tsx b/packages/extension/src/ui/components/ControlledTextArea.tsx deleted file mode 100644 index a0f0591f5..000000000 --- a/packages/extension/src/ui/components/ControlledTextArea.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { FieldError } from "@argent/x-ui" -import { Textarea, TextareaProps } from "@chakra-ui/react" -import { FC } from "react" -import { Control, Controller, FieldPath, FieldValues } from "react-hook-form" - -interface ControlledInputProps - extends TextareaProps { - control: Control - name: FieldPath -} - -export const ControlledTextArea: FC = ({ - control, - name, - ...props -}) => { - return ( - ( - <> -

?EG>}ecrfn1iWyov$h;75b{Zfz&kff`rqn@9w66X!qbI!PGj z$N7P+>(BaeIwVzMRIyph(CNTd_~jpF_I}iP-s+NQJ0f0@5;Yl#Sz+2yOL`P5+(3^RIO!>Ds@Dc`nALQsF{ckF_(2uue8HzBSMyWe+Tcsd;jf^ ze~33;tt!&3Mm^cuTu0*fX5LYGH(ZI>f70v~rs6fyApF3c_?+Brc?bSgL=p6V;T0YH zd)__`*kH58(?3;|kY+t^90_u_r9B10#oq9vQf-S>Pc20`?{2#uQ|!Y<#gimTF}6uU zB@)NKCIfI$!evdVQR;3?MO5qpLj3i=Y>0~}e0t6tHoQZANjl>9rUmZrMm5OgD0T{6nm^^~n^yNWaExveo5 zlEe9p!xlA9>t>y9TF9r?j-|tPJ%5~!yGEm%y2(QFEjCqsaNVgXZ_4W}EjPCx z+ti8b_15bl(uC{Zi-()|YYd@6sSwmYD~SPaZVMR;z~LK*4bVH98te4bdN&P+EJx_L z4|k`-YJNExbT@81kBAiNw=FyD;;D&;&6VdAK0WwC#XX8*(Yggm{~ul;F~7>rQW)I+ z7d9)G`>n0z)GT>gkrbJ2oG@$TwXb_YS7C~Jo29lTzj_uKTF%0dj@e54lzc;4;jN}z zTHcp?JgR}EZP<4n=>o%-bd^zu+$u@-YD7BkXY0i1?|FQ@!|3+Jo?CWS`8dbvr&5n^ zS5}VdcDXTdpCGqHpd7B#oZ}MDc_Au#Usx+yA}hp>yDV>oM*wrZ-Ownf_3HRnS$tM8$=Ej+}21n2v}>Hze0!))M5Vbv>drwWHf zDeu|LY%5}3W+;zz_2o}n0nz+8>tjaW4)Ld5>;}3>vjn`QQt%cn&VIZHnvrpFl1Vxw zOS%dH>3Fz7jbKkfJR!Zew(=wOAl|F&YcRR$wg09uCcQuC!!aBe{e*aZDUv4+CzC-u zO(8RLW%{U+H1)@*SA zjYr(SGzCQD3~{qiR!cNB={WeSz+XK1;>h|1V+q8gV~^RIN!dpa7H0 zCFaNQrI<=^a_1a%p=A|KzH6ljIRM?qgiY4*I&R;|7Wj_k#op!WBk3ZN)TTZg_bI61 zQl)H{5pgD$;HBi!B=io}KBek9`cb26{C&F**$oo%8+RLQl?1pYZh1EueWj&X3gvt# zwUGB3K?&nqr#i zrY=?Krk4A`5fzR(9>~~?%lJ&N&U$nV57LD($%{(*-`7xGLsLsz$Mb_QlJmuKwT?GQ zn&m~QkhH1l1|~=S;ZLtnMM=Q56J`s6&Au{61RRzV?lM_B-I9 z?RNOw7XVT=OKGOh;IoBUR-_i`XTLavhcD4`Th(x+K_Eml*K7;Sx6nx1bgDc{pCMzW z%%x_@n$042T(g+V4YyriA$)dGRu-{Y+16RtR+t~e+Uh%d3I3{pvaPwXh7_&}S2e%$ zZ5@Qhs+O(L+yuh^7W4=7hH;%84O_o=KvRT;#abf#jAO;R-P}KRK~M9bm{~0_u}E6D zZFe+(TM>Q1jLe82#48AZA_*ZBQHUE5Xahn(2z(a;4+0^80%lMKz*hhOAp`<$5CDL< zL7+eY5OCAP_i0tFcBZO|wyayxs!e+RMGiNuY3RSHp{s82=fwTK8vDJUgzZIIjbX-V zz|C4$;}&i!46|CNON}@$)~U@Wnuvb_FKcVFy_Q&0_Yb_;W>2g79K_z{w)(rTfr5vi z@1IL|!|>v=UGSV+zNBk6gm)f*G7S(vA=#&Iuu}qh + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/extension/src/assets/onboarding/account-smart.svg b/packages/extension/src/assets/onboarding/account-smart.svg new file mode 100644 index 000000000..450598838 --- /dev/null +++ b/packages/extension/src/assets/onboarding/account-smart.svg @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/extension/src/assets/onboarding/account-standard.svg b/packages/extension/src/assets/onboarding/account-standard.svg new file mode 100644 index 000000000..90b43e157 --- /dev/null +++ b/packages/extension/src/assets/onboarding/account-standard.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/extension/src/assets/onboarding/default.svg b/packages/extension/src/assets/onboarding/default.svg new file mode 100644 index 000000000..642018a5c --- /dev/null +++ b/packages/extension/src/assets/onboarding/default.svg @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/extension/src/assets/onboarding/email-wrong.svg b/packages/extension/src/assets/onboarding/email-wrong.svg new file mode 100644 index 000000000..2fd75123b --- /dev/null +++ b/packages/extension/src/assets/onboarding/email-wrong.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/extension/src/assets/onboarding/email.svg b/packages/extension/src/assets/onboarding/email.svg new file mode 100644 index 000000000..07cd9f305 --- /dev/null +++ b/packages/extension/src/assets/onboarding/email.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/extension/src/assets/onboarding/finish/2fa-protection.svg b/packages/extension/src/assets/onboarding/finish/2fa-protection.svg new file mode 100644 index 000000000..cb2035a36 --- /dev/null +++ b/packages/extension/src/assets/onboarding/finish/2fa-protection.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/packages/extension/src/assets/onboarding/finish/download-mobile.svg b/packages/extension/src/assets/onboarding/finish/download-mobile.svg new file mode 100644 index 000000000..f0046b420 --- /dev/null +++ b/packages/extension/src/assets/onboarding/finish/download-mobile.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/extension/src/assets/onboarding/finish/explore-dapps.svg b/packages/extension/src/assets/onboarding/finish/explore-dapps.svg new file mode 100644 index 000000000..1fac576ab --- /dev/null +++ b/packages/extension/src/assets/onboarding/finish/explore-dapps.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/packages/extension/src/assets/onboarding/finish/follow-us-on-x.svg b/packages/extension/src/assets/onboarding/finish/follow-us-on-x.svg new file mode 100644 index 000000000..4bef6aeb0 --- /dev/null +++ b/packages/extension/src/assets/onboarding/finish/follow-us-on-x.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/extension/src/assets/onboarding/finish/on-chain-recovery.svg b/packages/extension/src/assets/onboarding/finish/on-chain-recovery.svg new file mode 100644 index 000000000..2cad248a6 --- /dev/null +++ b/packages/extension/src/assets/onboarding/finish/on-chain-recovery.svg @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/extension/src/assets/onboarding/finish/session-keys.svg b/packages/extension/src/assets/onboarding/finish/session-keys.svg new file mode 100644 index 000000000..9dd0a89e8 --- /dev/null +++ b/packages/extension/src/assets/onboarding/finish/session-keys.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/packages/extension/src/assets/onboarding/improve.svg b/packages/extension/src/assets/onboarding/improve.svg new file mode 100644 index 000000000..a2343a23f --- /dev/null +++ b/packages/extension/src/assets/onboarding/improve.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/extension/src/assets/onboarding/password-created.svg b/packages/extension/src/assets/onboarding/password-created.svg new file mode 100644 index 000000000..2307a2a23 --- /dev/null +++ b/packages/extension/src/assets/onboarding/password-created.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/packages/extension/src/assets/onboarding/password.svg b/packages/extension/src/assets/onboarding/password.svg new file mode 100644 index 000000000..a8897e86d --- /dev/null +++ b/packages/extension/src/assets/onboarding/password.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/packages/extension/src/assets/staking/liquid-staking.png b/packages/extension/src/assets/staking/liquid-staking.png new file mode 100644 index 0000000000000000000000000000000000000000..6f6cc9385b69b736a3bae6c12f223caa730855cd GIT binary patch literal 168556 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