From 1354894755764c1e59a6f0088736ec4c0335443f Mon Sep 17 00:00:00 2001 From: Gerald Date: Wed, 24 May 2023 12:11:43 +0200 Subject: [PATCH] Release v5.4 --- .github/workflows/pull-request.yml | 164 +- .github/workflows/release.yml | 64 +- .gitignore | 2 + .vscode/extensions.json | 3 + .vscode/settings.json | 6 +- Readme.md | 2 + lerna.json | 2 +- package.json | 19 +- packages/dapp/next.config.js | 2 +- packages/dapp/package.json | 8 +- packages/dapp/src/components/TokenDapp.tsx | 147 +- packages/dapp/src/pages/index.tsx | 75 +- packages/dapp/src/services/token.service.ts | 40 +- packages/dapp/src/services/wallet.service.ts | 113 +- packages/dapp/src/styles/globals.css | 27 +- packages/{extension => }/e2e/.eslintrc.js | 0 packages/{extension => }/e2e/.gitignore | 0 packages/{extension => }/e2e/Dockerfile | 0 .../extension}/network-setup/Dockerfile | 0 .../network-setup/build_and_push.sh | 0 .../extension}/network-setup/dump.pkl | Bin .../e2e => e2e/extension}/src/config.ts | 6 +- .../e2e => e2e/extension}/src/fixtures.ts | 0 .../extension}/src/languages/ILanguage.ts | 1 - .../extension}/src/languages/en/index.ts | 1 - .../extension}/src/languages/index.ts | 1 + .../extension}/src/page-objects/Account.ts | 18 +- .../extension}/src/page-objects/Activity.ts | 0 .../src/page-objects/AddressBook.ts | 0 .../src/page-objects/DeveloperSettings.ts | 0 .../src/page-objects/ExtensionPage.ts | 0 .../extension}/src/page-objects/Navigation.ts | 0 .../extension}/src/page-objects/Network.ts | 0 .../extension}/src/page-objects/Settings.ts | 2 +- .../extension}/src/page-objects/Wallet.ts | 16 +- .../src/specs/accountSettings.spec.ts | 0 .../extension}/src/specs/addressBook.spec.ts | 0 .../extension}/src/specs/dappsBanner.spec.ts | 0 .../extension}/src/specs/links.spec.ts | 0 .../extension}/src/specs/network.spec.ts | 2 +- .../extension}/src/specs/receiveFunds.spec.ts | 0 .../extension}/src/specs/recovery.spec.ts | 0 .../extension/src/specs/sendFundsMax.spec.ts | 78 + .../src/specs/sendFundsPartial.spec.ts} | 53 +- .../extension}/src/specs/welcome.spec.ts | 0 .../e2e => e2e/extension}/src/test.ts | 4 +- .../extension}/src/utils/Messages.ts | 0 packages/e2e/package.json | 15 + .../{extension => e2e}/playwright.config.ts | 11 +- packages/{extension => }/e2e/tsconfig.json | 6 +- packages/extension/.env.example | 4 +- packages/extension/.eslintrc.js | 10 + packages/extension/manifest/v2.json | 2 +- packages/extension/manifest/v3.json | 2 +- packages/extension/package.json | 40 +- packages/extension/sonar-project.properties | 6 + .../extension/src/assets/default-tokens.json | 8 + .../background/__new/middleware/analytics.ts | 65 + .../background/__new/middleware/session.ts | 13 + .../__new/procedures/account/create.ts | 49 + .../__new/procedures/account/deploy.ts | 14 + .../__new/procedures/account/index.ts | 10 + .../__new/procedures/account/upgrade.ts | 33 + .../__new/procedures/network/add.ts | 11 + .../__new/procedures/network/index.ts | 6 + .../__new/procedures/permissions.ts | 45 + .../__new/procedures/recovery/index.ts | 8 + .../procedures/recovery/recoverBackup.ts | 20 + .../procedures/recovery/recoverSeedphrase.ts | 41 + .../extension/src/background/__new/router.ts | 32 + .../onboarding/implementation.test.ts | 85 + .../services/onboarding/implementation.ts | 38 + .../__new/services/onboarding/index.ts | 17 + .../__new/services/onboarding/interface.ts | 13 + .../onboarding/worker/implementation.test.ts | 54 + .../onboarding/worker/implementation.ts | 62 + .../extension/src/background/__new/trpc.ts | 24 + .../extension/src/background/accountDeploy.ts | 10 + .../src/background/accountMessaging.ts | 118 +- .../src/background/accountUpgrade.ts | 6 +- .../src/background/actionHandlers.ts | 78 +- packages/extension/src/background/crypto.ts | 5 +- packages/extension/src/background/index.ts | 153 +- .../src/background/miscellaneousMessaging.ts | 8 +- .../src/background/multisigDeployAction.ts | 64 + .../src/background/multisigMessaging.ts | 260 + .../src/background/network/network.service.ts | 31 + .../networkStatus.worker.ts} | 8 +- .../src/background/networkMessaging.ts | 59 +- packages/extension/src/background/nonce.ts | 15 +- .../extension/src/background/notification.ts | 34 +- .../extension/src/background/onboarding.ts | 14 +- .../src/background/recoveryMessaging.ts | 63 - .../src/background/sessionMessaging.ts | 6 +- .../src/background/shieldMessaging.ts | 8 +- .../src/background/transactions/badgeText.ts | 63 +- .../fees/multisigFeeEstimation.ts | 32 + .../background/transactions/onupdate/index.ts | 2 + .../transactions/onupdate/multisigUpdates.ts | 15 + .../transactions/onupdate/notifications.ts | 8 +- .../background/transactions/onupdate/type.ts | 4 +- .../src/background/transactions/store.ts | 10 +- .../transactions/transactionExecution.ts | 56 +- .../transactions/transactionMessaging.ts | 764 +- .../extension/src/background/udcMessaging.ts | 7 +- packages/extension/src/background/wallet.ts | 514 +- .../src/background/walletSingleton.ts | 19 + packages/extension/src/content.ts | 8 +- .../extension/src/inpage/ArgentXAccount.ts | 2 +- .../extension/src/inpage/ArgentXAccount3.ts | 2 +- packages/extension/src/inpage/messaging.ts | 13 - .../src/inpage/requestMessageHandlers.ts | 67 +- .../src/inpage/starknetWindowObject.ts | 14 +- packages/extension/src/inpage/trpcClient.ts | 8 + .../__new/services/ui/implementation.test.ts | 74 + .../__new/services/ui/implementation.ts | 71 + .../src/shared/__new/services/ui/index.ts | 5 + .../src/shared/__new/services/ui/interface.ts | 51 + .../details/getAccountTypesFromChain.ts | 19 +- .../details/getAndMergeAccountDetails.test.ts | 6 +- .../src/shared/account/details/getEscape.ts | 14 +- .../details/updateAccountsWithNames.ts | 22 + .../account/service/implementation.test.ts | 82 + .../shared/account/service/implementation.ts | 152 + .../src/shared/account/service/index.ts | 10 + .../src/shared/account/service/interface.ts | 34 + .../extension/src/shared/account/store.ts | 57 - .../src/shared/account/store/index.ts | 20 + .../shared/account/store/serialize.test.ts | 46 + .../shared/account/{ => store}/serialize.ts | 4 +- .../src/shared/account/storeMigration.ts | 4 +- .../extension/src/shared/account/update.ts | 46 +- .../extension/src/shared/actionQueue/types.ts | 27 +- packages/extension/src/shared/analytics.ts | 23 +- .../extension/src/shared/api/constants.ts | 11 + .../shared/call/changeMultisigSignersCall.ts | 37 + .../shared/call/setMultisigThresholdCalls.ts | 19 + .../extension/src/shared/explorer/type.ts | 4 +- .../src/shared/messages/AccountMessage.ts | 28 +- .../src/shared/messages/ActionMessage.ts | 4 +- .../src/shared/messages/MultisigMessage.ts | 74 + .../src/shared/messages/NetworkMessage.ts | 21 +- .../src/shared/messages/RecoveryMessage.ts | 9 - .../extension/src/shared/messages/index.ts | 8 +- .../extension/src/shared/multisig/account.ts | 257 + .../multisig/mocks/executeTransaction.mock.ts | 35 + .../src/shared/multisig/multisig.model.ts | 130 + .../src/shared/multisig/multisig.service.ts | 176 + .../multisig/pendingTransactionsStore.ts | 138 + .../extension/src/shared/multisig/signer.ts | 44 + .../extension/src/shared/multisig/store.ts | 20 + .../extension/src/shared/multisig/tracking.ts | 207 + .../extension/src/shared/multisig/types.ts | 12 + .../src/shared/multisig/utils/baseMultisig.ts | 102 + .../shared/multisig/utils/pendingMultisig.ts | 116 + .../src/shared/multisig/utils/selectors.ts | 18 + .../extension/src/shared/network/defaults.ts | 29 +- .../extension/src/shared/network/index.ts | 21 +- .../extension/src/shared/network/provider.ts | 9 + .../extension/src/shared/network/schema.ts | 123 +- .../shared/network/service/implementation.ts | 11 + .../src/shared/network/service/interface.ts | 5 + .../extension/src/shared/network/storage.ts | 11 +- packages/extension/src/shared/network/type.ts | 26 +- .../extension/src/shared/network/utils.ts | 56 +- .../src/shared/network/view/index.ts | 4 + .../extension/src/shared/preAuthorizations.ts | 5 +- .../extension/src/shared/schemas/address.ts | 26 + packages/extension/src/shared/schemas/hex.ts | 17 + .../src/shared/schemas/seedphrase.ts | 7 + .../extension/src/shared/shield/jwtFetcher.ts | 2 +- .../__test__/inmemoryImplementations.test.ts | 91 + .../__new/__test__/inmemoryImplementations.ts | 147 + .../storage/__new/__test__/keyvalue.test.ts | 61 + .../__test__/mockFunctionImplementation.ts | 18 + .../src/shared/storage/__new/interface.ts | 106 + .../src/shared/storage/__new/keyvalue.ts | 64 + .../src/shared/storage/__new/object.ts | 23 + .../storage/__new/repositories/network.ts | 4 + .../src/shared/storage/__new/repository.ts | 37 + .../shared/storage/__test__/keyvalue.test.ts | 2 +- .../extension/src/shared/storage/hooks.ts | 15 +- .../extension/src/shared/storage/keyvalue.ts | 10 + .../src/shared/transactionReview.service.ts | 2 +- packages/extension/src/shared/transactions.ts | 40 +- .../extension/src/shared/types/deepPick.ts | 43 + .../src/shared/utils/accountsMultisigSort.ts | 25 + packages/extension/src/shared/utils/encode.ts | 3 + .../src/shared/utils/starknetNetwork.ts | 70 + packages/extension/src/shared/wallet.model.ts | 101 +- .../extension/src/shared/wallet.service.ts | 8 + .../src/shared/wallet/storeMigration.ts | 8 +- .../src/shared/wallet/walletStore.ts | 11 +- packages/extension/src/ui/App.tsx | 4 +- packages/extension/src/ui/AppRoutes.tsx | 157 +- packages/extension/src/ui/app.state.ts | 4 +- .../extension/src/ui/components/Column.tsx | 4 +- .../src/ui/components/ControlledInput.tsx | 38 + .../src/ui/components/CustomButtonCell.tsx | 5 +- .../extension/src/ui/components/Fields.tsx | 38 - .../src/ui/components/FullScreenPage.tsx | 83 +- .../extension/src/ui/components/IOSSwitch.tsx | 2 +- .../ui/components/PrivacyStatementLink.tsx | 18 - packages/extension/src/ui/components/Row.tsx | 4 +- .../src/ui/components/StatusIndicator.tsx | 36 +- .../utils/isAllowedAddressHexInputValue.tsx | 2 +- .../accountActivity/AccountActivity.tsx | 3 +- .../AccountActivityContainer.tsx | 22 +- .../PendingMultisigTransactions.tsx | 168 + .../accountActivity/TransactionDetail.tsx | 6 +- .../TransactionDetailScreen.tsx | 11 +- .../accountActivity/TransactionListItem.tsx | 79 +- .../__test__/transformTransaction.test.ts | 10 +- .../transaction/transformTransaction.ts | 4 + .../changeMultisigThresholdTransformer.ts | 22 + .../transformers/changeMultisigTransformer.ts | 27 + .../transformers/guardianTransformer.ts | 4 +- .../pendingMultisigTransactionAdapter.ts | 19 + .../accountActivity/transform/type.ts | 17 + .../accountActivity/ui/LoadMoreTrigger.tsx | 6 +- .../features/accountActivity/ui/NFTImage.tsx | 6 +- .../ui/SwapTransactionIcon.tsx | 7 +- .../accountActivity/ui/TransactionIcon.tsx | 20 +- .../features/accountActivity/useActivity.ts | 3 + .../accountEdit/AccountEditButtons.tsx | 153 + .../AccountEditButtonsMultisig.tsx | 74 + .../features/accountEdit/AccountEditName.tsx | 12 +- .../accountEdit/AccountEditScreen.tsx | 178 +- .../AccountImplementationScreen.tsx | 27 +- .../accountNfts/AccountCollections.tsx | 3 +- .../features/accountNfts/CollectionNfts.tsx | 136 +- .../features/accountNfts/NftModelViewer.tsx | 2 +- .../src/ui/features/accountNfts/NftScreen.tsx | 5 +- .../ui/features/accountNfts/SendNftScreen.tsx | 84 +- .../ui/features/accountNfts/aspect.model.ts | 11 + .../ui/features/accountNfts/aspect.service.ts | 27 +- .../ui/features/accountNfts/useCollections.ts | 50 +- .../ui/features/accountPlugins.tsx/Plugin.tsx | 2 +- .../accountPlugins.tsx/PluginAccount.ts | 2 +- .../features/accountTokens/AccountTokens.tsx | 158 +- .../accountTokens/AccountTokensButtons.tsx | 92 +- .../accountTokens/AccountTokensHeader.tsx | 22 +- .../accountTokens/ActivateMultisigBanner.tsx | 22 + .../accountTokens/ExportPrivateKeyScreen.tsx | 2 +- .../accountTokens/SendTokenScreen.tsx | 33 +- .../ui/features/accountTokens/TokenIcon.tsx | 8 +- .../ui/features/accountTokens/TokenList.tsx | 6 + .../features/accountTokens/TokenListItem.tsx | 8 +- .../ui/features/accountTokens/TokenMenu.tsx | 2 +- .../accountTokens/TokenMenuDeprecated.tsx | 10 +- .../accountTokens/dappland/banner.state.ts | 4 +- .../features/accountTokens/tokenPriceHooks.ts | 2 +- .../features/accountTokens/tokens.service.ts | 3 +- .../features/accountTokens/usePrivateKey.ts | 5 +- .../accountTokens/useTransactionStatus.ts | 12 +- .../src/ui/features/accounts/Account.ts | 35 +- .../ui/features/accounts/AccountAvatar.tsx | 40 + .../ui/features/accounts/AccountContainer.tsx | 146 +- .../src/ui/features/accounts/AccountLabel.tsx | 41 + .../accounts/AccountListHiddenScreen.test.tsx | 50 + .../accounts/AccountListHiddenScreen.tsx | 64 +- .../AccountListHiddenScreenContainer.tsx | 66 + .../accounts/AccountListHiddenScreenItem.tsx | 31 - .../ui/features/accounts/AccountListItem.tsx | 147 +- .../accounts/AccountListItemShieldBadge.tsx | 52 + .../AccountListItemShieldBadgeContainer.tsx | 19 + .../accounts/AccountListItemUpgradeBadge.tsx | 23 + .../features/accounts/AccountListScreen.tsx | 205 +- .../accounts/AccountListScreenContainer.tsx | 129 + .../accounts/AccountListScreenItem.test.tsx | 64 + .../accounts/AccountListScreenItem.tsx | 183 +- .../AccountListScreenItemAccessory.tsx | 15 + .../AccountListScreenItemContainer.tsx | 78 + .../accounts/AccountNavigationBar.test.tsx | 34 + .../accounts/AccountNavigationBar.tsx | 62 +- .../AccountNavigationBarContainer.tsx | 56 + .../ui/features/accounts/AccountScreen.tsx | 12 +- .../accounts/AccountScreenEmpty.test.tsx | 24 + .../features/accounts/AccountScreenEmpty.tsx | 54 +- .../accounts/AccountScreenEmptyContainer.tsx | 47 + .../ui/features/accounts/AccountSelect.tsx | 222 +- .../accounts/AddNewAccountScreen.test.tsx | 46 + .../features/accounts/AddNewAccountScreen.tsx | 173 +- .../accounts/AddNewAccountScreenContainer.tsx | 121 + .../accounts/AddressBookMenu.test.tsx | 93 + .../ui/features/accounts/AddressBookMenu.tsx | 215 +- .../ui/features/accounts/DeployAccount.tsx | 144 - .../accounts/DeployAccountScreen.test.tsx | 27 + .../features/accounts/DeployAccountScreen.tsx | 41 + .../accounts/DeployAccountScreenContainer.tsx | 30 + .../accounts/DeprecatedAccountScreen.tsx | 58 - .../features/accounts/GroupedAccountList.tsx | 78 + .../features/accounts/HiddenAccountsBar.tsx | 37 +- .../accounts/HiddenAccountsBarContainer.tsx | 38 + .../HideOrDeleteAccountConfirmScreen.test.tsx | 28 + .../HideOrDeleteAccountConfirmScreen.tsx | 134 +- ...eOrDeleteAccountConfirmScreenContainer.tsx | 67 + .../MigrationDisclaimerScreen.test.tsx | 16 + .../accounts/MigrationDisclaimerScreen.tsx | 41 + .../MigrationDisclaimerScreenContainer.tsx | 32 + .../accounts/PrettyAccountAddress.tsx | 44 +- .../ui/features/accounts/ProfilePicture.tsx | 32 - .../ui/features/accounts/SectionHeader.tsx | 8 - .../features/accounts/UpgradeScreen.test.tsx | 22 + .../ui/features/accounts/UpgradeScreen.tsx | 75 +- .../accounts/UpgradeScreenContainer.tsx | 32 + .../accounts/UpgradeScreenV4.test.tsx | 68 + .../ui/features/accounts/UpgradeScreenV4.tsx | 309 +- .../accounts/UpgradeScreenV4Container.tsx | 104 + .../ui/features/accounts/WarningScreen.tsx | 81 + .../accounts/accountMetadata.state.ts | 142 +- .../features/accounts/accountMetadata.test.ts | 116 + .../accounts/accountTransactions.state.ts | 20 + .../ui/features/accounts/accounts.service.ts | 17 +- .../ui/features/accounts/accounts.state.ts | 101 +- .../src/ui/features/accounts/switchAccount.ts | 27 +- .../accounts/ui/StarknetAccountMessage.tsx | 83 + .../ui/features/accounts/upgrade.service.ts | 87 +- .../src/ui/features/accounts/useAddAccount.ts | 36 - .../src/ui/features/accounts/usePublicKey.ts | 76 + .../ui/features/actions/AccountAddress.tsx | 8 +- .../src/ui/features/actions/ActionScreen.tsx | 361 +- .../ui/features/actions/AddNetworkScreen.tsx | 139 - .../AddNetworkScreen.test.tsx | 69 + .../AddNetworkScreen/AddNetworkScreen.tsx | 115 + .../AddNetworkScreenContainer.tsx | 47 + .../actions/AddTokenActionScreenContainer.tsx | 23 + .../ui/features/actions/AddTokenScreen.tsx | 168 +- .../actions/AddTokenScreenContainer.tsx | 176 + .../actions/ApproveDeclareContractScreen.tsx | 63 - .../features/actions/ApproveDeployAccount.tsx | 36 +- .../actions/ApproveDeployContractScreen.tsx | 63 - .../actions/ApproveSignatureScreen.tsx | 74 +- .../ApproveSignatureScreenContainer.tsx | 62 + .../DeclareContractActionScreenContainer.tsx | 75 + .../DeployAccountActionScreenContainer.tsx | 69 + .../DeployContractActionScreenContainer.tsx | 75 + .../DeployMultisigActionScreenContainer.tsx | 68 + .../actions/DeprecatedConfirmScreen.tsx | 25 +- .../src/ui/features/actions/ErrorScreen.tsx | 48 +- .../features/actions/ErrorScreenContainer.tsx | 17 + .../src/ui/features/actions/LoadingScreen.tsx | 48 +- .../actions/LoadingScreenContainer.tsx | 14 + .../actions/SignActionScreenContainer.tsx | 44 + .../TransactionActionScreenContainer.tsx | 75 + .../ConnectDappAccountListItem.tsx | 36 - .../connectDapp/ConnectDappAccountSelect.tsx | 86 + .../actions/connectDapp/ConnectDappScreen.tsx | 273 +- .../ConnectDappScreenContainer.tsx | 86 + .../features/actions/connectDapp/DappIcon.tsx | 37 +- .../AccountDeploymentFeeEstimation.tsx | 43 - .../feeEstimation/CombinedFeeEstimation.tsx | 95 +- .../CombinedFeeEstimationContainer.tsx | 96 + .../DeclareContractFeeEstimation.tsx | 41 - .../DeclareDeploy/Estimation.tsx | 230 - .../DeclareDeploy/NetworkFee.tsx | 46 - .../DeclareDeploy/TokenAmounts.tsx | 99 - .../useEstimationAccountFees.tsx | 21 - .../DeployAccountFeeEstimation.tsx | 84 + .../DeployContractFeeEstimation.tsx | 33 - .../actions/feeEstimation/FeeEstimation.tsx | 77 +- .../feeEstimation/FeeEstimationContainer.tsx | 68 + .../features/actions/feeEstimation/styled.tsx | 83 - .../features/actions/feeEstimation/types.ts | 10 + .../features/actions/hooks/useActionScreen.ts | 70 + .../transaction/ApproveDeployMultisig.tsx | 73 + .../ApproveTransactionScreen.tsx | 194 + .../ApproveTransactionScreenContainer.tsx | 141 + .../BalanceChangeOverview/NftDetails.tsx | 54 +- .../BalanceChangeOverview/index.tsx | 6 +- .../ConfirmScreen.tsx | 154 +- .../TransactionIcon/ActivateAccountIcon.tsx | 13 + .../TransactionIcon/ActivateMultisigIcon.tsx | 13 + .../TransactionIcon/AddArgentShieldIcon.tsx | 13 + .../TransactionIcon/AddOwnerIcon.tsx | 13 + .../RemoveArgentShieldIcon.tsx | 13 + .../TransactionIcon/RemoveOwnerIcon.tsx | 13 + .../TransactionIcon/UpdateThresholdIcon.tsx | 13 + .../DappHeader/TransactionIcon/index.test.tsx | 17 +- .../DappHeader/TransactionIcon/index.tsx | 100 +- .../DappHeader/TransactionTitle.tsx | 71 +- .../DappHeader/index.tsx | 16 +- .../MultisigBanner.tsx | 68 + .../TransactionActions.tsx | 95 +- .../TransactionBanner.tsx | 62 +- .../ApproveTransactionScreen/index.tsx | 254 - .../transaction/DefaultTransactionDetails.tsx | 82 - .../ERC20ApproveTransactionDetails.tsx | 51 - .../ERC20TransferTransactionDetails.tsx | 58 - .../transaction/TransactionDetails.tsx | 48 - .../transaction/TransactionsListSwap.tsx | 101 - .../fields/AccountAddressField.tsx | 4 +- .../transaction/fields/ContractField.tsx | 4 +- .../transaction/fields/DappContractField.tsx | 47 +- .../actions/transaction/fields/FeeField.tsx | 23 +- .../fields/MaybeDappContractField.tsx | 18 + .../transaction/fields/ParameterField.tsx | 32 +- .../actions/transaction/fields/TokenField.tsx | 4 +- .../ui/features/actions/transaction/types.ts | 42 + .../useTransactionSimulatedData.ts | 2 +- .../src/ui/features/actions/utils.ts | 44 + .../features/funding/FundingBridgeScreen.tsx | 5 +- .../funding/FundingProviderScreen.tsx | 7 +- .../features/funding/FundingQrCodeScreen.tsx | 12 +- .../src/ui/features/funding/FundingScreen.tsx | 5 +- .../src/ui/features/multisig/AddOwnerForm.tsx | 96 + .../CreateMultisigStartScreen.tsx | 53 + .../MultisigFirstStep.test.tsx | 64 + .../MultisigFirstStep.tsx | 57 + .../MultisigSecondStep.tsx | 90 + .../MultisigThirdStep.tsx | 36 + .../CreateMultisigScreen/ScreenLayout.tsx | 114 + .../features/multisig/JoinMultisigScreen.tsx | 79 + .../multisig/JoinMultisigSettingsScreen.tsx | 101 + .../src/ui/features/multisig/Multisig.ts | 115 + .../multisig/MultisigAddOwnersScreen.tsx | 88 + .../ui/features/multisig/MultisigBanner.tsx | 58 + .../multisig/MultisigConfirmationsScreen.tsx | 183 + .../features/multisig/MultisigDeleteModal.tsx | 62 + .../multisig/MultisigListAccounts.tsx | 51 + .../multisig/MultisigOwnersScreen.tsx | 110 + ...ultisigPendingTransactionDetailsScreen.tsx | 233 + .../multisig/MultisigPendingTxModal.tsx | 57 + .../multisig/MultisigRemoveOwnerScreen.tsx | 92 + .../multisig/MultisigSettingsWrapper.tsx | 31 + ...MultisigTransactionConfirmationsScreen.tsx | 136 + .../features/multisig/NewMultisigScreen.tsx | 144 + .../multisig/PendingMultisigListItem.tsx | 94 + .../PendingMultisigListScreenItem.tsx | 86 + .../RemoveMultisigSettingScreen.test.tsx | 44 + .../RemovedMultisigSettingsScreen.tsx | 68 + ...RemovedMultisigSettingsScreenContainer.tsx | 71 + .../multisig/RemovedMultisigWarningScreen.tsx | 16 + .../multisig/SetConfirmationsInput.tsx | 78 + .../multisig/hooks/useCreateMultisigForm.ts | 49 + .../hooks/useCreatePendingMultisig.ts | 19 + .../multisig/hooks/useIsMultisigDeploying.ts | 21 + .../multisig/hooks/useIsSignerInMultisig.ts | 20 + .../hooks/useMultisigDataforAccount.ts | 58 + .../multisig/hooks/useUpdateThreshold.ts | 30 + .../src/ui/features/multisig/multisig.mock.ts | 85 + .../ui/features/multisig/multisig.state.ts | 180 + .../multisig/multisigTransactions.state.ts | 60 + .../ui/features/networks/NetworkSwitcher.tsx | 128 - .../NetworkSwitcher/NetworkSwitcher.test.tsx | 128 + .../NetworkSwitcher/NetworkSwitcherButton.tsx | 38 + .../NetworkSwitcherContainer.tsx | 61 + .../NetworkSwitcher/NetworkSwitcherList.tsx | 77 + .../NetworkWarningScreen.test.tsx | 29 + .../NetworkWarningScreen.tsx | 21 +- .../NetworkWarningScreenContainer.tsx | 18 + .../networks/hooks/useCurrentNetwork.ts | 7 + .../features/networks/hooks/useIsMainnet.ts | 6 + .../useNeedsToShowNetworkStatusWarning.ts} | 4 +- .../ui/features/networks/hooks/useNetwork.ts | 13 + .../ui/features/networks/hooks/useNetworks.ts | 8 + .../useShouldShowNetworkUpgradeMessage.ts} | 6 +- .../src/ui/features/networks/useNetworks.ts | 59 - .../onboarding/MigrationDisclaimerScreen.tsx | 81 - .../OnboardingDisclaimerScreen.test.tsx | 44 + .../onboarding/OnboardingDisclaimerScreen.tsx | 141 +- .../OnboardingDisclaimerScreenContainer.tsx | 38 + .../OnboardingFinishScreen.test.tsx | 78 + .../onboarding/OnboardingFinishScreen.tsx | 185 +- .../OnboardingFinishScreenContainer.tsx | 17 + .../OnboardingPasswordScreen.test.tsx | 88 + .../onboarding/OnboardingPasswordScreen.tsx | 205 +- .../OnboardingPasswordScreenContainer.tsx | 76 + .../OnboardingPrivacyStatementScreen.test.tsx | 42 + .../OnboardingPrivacyStatementScreen.tsx | 25 +- ...oardingPrivacyStatementScreenContainer.tsx | 9 + .../onboarding/OnboardingRestoreBackup.tsx | 88 - .../OnboardingRestoreBackupScreen.test.tsx | 46 + .../OnboardingRestoreBackupScreen.tsx | 70 + ...OnboardingRestoreBackupScreenContainer.tsx | 41 + .../onboarding/OnboardingRestorePassword.tsx | 49 - ...boardingRestorePasswordScreenContainer.tsx | 65 + .../onboarding/OnboardingRestoreSeed.tsx | 101 - .../OnboardingRestoreSeedScreen.test.tsx | 44 + .../OnboardingRestoreSeedScreen.tsx | 101 + .../OnboardingRestoreSeedScreenContainer.tsx | 44 + .../onboarding/OnboardingStartScreen.test.tsx | 19 + .../onboarding/OnboardingStartScreen.tsx | 91 +- .../OnboardingStartScreenContainer.tsx | 31 + .../onboarding/StickyArgentFooter.tsx | 27 - .../onboarding/hooks/useOnboardingScreen.ts | 59 + .../hooks/useOnboardingToastMessage.tsx | 19 + .../onboarding/ui/OnboardingButton.tsx | 13 +- .../onboarding/ui/OnboardingCheckbox.tsx | 49 + .../onboarding/ui/OnboardingRectButton.tsx | 19 + .../onboarding/ui/OnboardingScreen.tsx | 109 +- .../onboarding/ui/OnboardingToastMessage.tsx | 31 + .../ui/features/onboarding/ui/RectButton.tsx | 42 - .../recovery/BackupDownloadScreen.tsx | 51 - .../ui/features/recovery/CopySeedPhrase.tsx | 36 +- .../features/recovery/RecoverySetupScreen.tsx | 34 +- .../src/ui/features/recovery/SeedPhrase.tsx | 58 +- .../recovery/SeedRecoveryConfirmScreen.tsx | 6 +- .../recovery/SeedRecoverySetupScreen.tsx | 9 +- .../features/recovery/backupDownload.state.ts | 4 +- .../recovery/{ => hooks}/useCustomNavigate.ts | 4 +- .../recovery/{ => hooks}/useSeedPhrase.ts | 2 +- .../ui/features/recovery/recovery.service.ts | 20 +- .../recovery/seedRecovery.state.test.ts | 113 + .../features/recovery/seedRecovery.state.ts | 15 +- .../recovery/ui/CircleIconContainer.tsx | 23 + .../features/recovery/ui/ComingSoonIcon.tsx | 22 + .../recovery/ui/CopySeedPhraseButton.tsx | 37 + .../recovery/ui/LoadingSeedWordBadge.tsx | 31 + .../features/recovery/ui/SeedPhraseGrid.tsx | 20 + .../ui/features/recovery/ui/SeedWordBadge.tsx | 25 + .../recovery/ui/SeedWordBadgeNumber.tsx | 24 + .../ui/features/recovery/ui/WarningText.tsx | 14 + .../settings/AddressbookAddOrEditScreen.tsx | 11 +- .../settings/AddressbookSettingsScreen.tsx | 5 +- .../DeveloperSettings/ClassHashOption.tsx | 14 +- .../DeclareSmartContractForm.tsx | 17 +- .../DeploySmartContractForm.tsx | 16 +- .../DeploySmartContractParameters.tsx | 2 +- .../DeveloperSettings/SelectOptionAccount.tsx | 7 +- .../DeveloperSettings/useFormSelects.tsx | 21 +- .../settings/ExperimentalSettings.tsx | 15 +- .../settings/NetworkSettingsFormScreen.tsx | 51 +- .../settings/NetworkSettingsScreen.tsx | 7 +- .../features/settings/SeedSettingsScreen.tsx | 2 +- .../ui/features/settings/SettingsMenuItem.tsx | 6 +- .../ui/features/settings/SettingsScreen.tsx | 13 +- .../SmartContractDevelopmentScreen.tsx | 9 +- .../ui/features/settings/SupportFooter.tsx | 2 +- .../settings/selectedNetwork.state.ts | 4 +- .../settings/validateRemoveNetwork.ts | 4 +- .../shield/ShieldAccountActionScreen.tsx | 2 +- .../features/shield/ShieldAccountActivate.tsx | 13 +- .../shield/ShieldAccountDeactivate.tsx | 9 +- .../shield/ShieldAccountFinishScreen.tsx | 9 +- .../shield/ShieldAccountStartScreen.tsx | 6 +- .../features/shield/ShieldBaseOTPScreen.tsx | 106 +- .../shield/WithArgentShieldVerified.tsx | 20 +- .../shield/escape/escapeWarningStore.ts | 4 +- .../shield/escape/useAccountEscape.ts | 33 +- .../src/ui/features/shield/shield.state.ts | 4 +- .../ui/features/shield/useAccountGuardian.ts | 9 +- .../src/ui/features/shield/useRouteAccount.ts | 2 +- .../stateRestoration/restoration.state.ts | 4 +- .../extension/src/ui/features/swap/Swap.tsx | 3 +- .../src/ui/features/swap/ui/OwnedToken.tsx | 6 +- .../src/ui/features/swap/ui/TokenPrice.tsx | 3 +- packages/extension/src/ui/hooks/useAction.ts | 39 + .../src/ui/hooks/useNavigateReturnTo.ts | 33 + packages/extension/src/ui/routes.ts | 72 +- .../src/ui/services/account/clientTrpc.ts | 17 + .../src/ui/services/account/index.ts | 7 + .../src/ui/services/account/interface.ts | 9 + .../extension/src/ui/services/addressBook.ts | 10 +- .../extension/src/ui/services/addresses.ts | 22 +- .../extension/src/ui/services/analytics.ts | 6 +- .../src/ui/services/backgroundAccounts.ts | 121 +- .../src/ui/services/backgroundMultisigs.ts | 108 + .../src/ui/services/backgroundNetworks.ts | 7 - .../src/ui/services/backgroundRecovery.ts | 44 - .../src/ui/services/backgroundTransactions.ts | 9 - .../src/ui/services/extension/clientLegacy.ts | 41 + .../src/ui/services/extension/index.ts | 14 + .../src/ui/services/extension/interface.ts | 3 + .../src/ui/services/messaging/trpc.ts | 10 + .../ui/services/recovery/implementation.ts | 18 + .../src/ui/services/recovery/index.ts | 3 + .../src/ui/services/recovery/interface.ts | 4 + packages/extension/src/ui/test/utils.tsx | 36 + packages/extension/src/ui/useEntryRoute.tsx | 8 + packages/extension/src/ui/views/account.ts | 88 + packages/extension/src/ui/views/defaults.ts | 3 + .../ui/views/implementation/atomFromRepo.ts | 34 + .../ui/views/implementation/atomFromStore.ts | 36 + .../src/ui/views/implementation/react.ts | 3 + .../extension/test/__mocks__/Svg.mock.tsx | 3 + packages/extension/test/account.mock.ts | 74 + packages/extension/test/backup.mock.json | 1 + packages/extension/test/keyDerivation.test.ts | 1 - packages/extension/test/networkSchema.test.ts | 34 +- packages/extension/test/setup.ts | 7 +- packages/extension/test/wallet.test.ts | 62 +- packages/extension/test/walletAccount.mock.ts | 38 + packages/extension/tsconfig.json | 4 +- packages/extension/vite.config.ts | 17 + packages/extension/webpack.config.js | 50 +- packages/get-starknet/.eslintrc.json | 28 + packages/get-starknet/.gitignore | 24 + packages/get-starknet/CHANGELOG.md | 9 + packages/get-starknet/README.md | 22 - packages/get-starknet/package.json | 64 +- packages/get-starknet/postcss.config.cjs | 8 + packages/get-starknet/rollup.config.mjs | 31 - packages/get-starknet/src/index.ts | 55 - packages/get-starknet/src/main.ts | 153 + packages/get-starknet/src/modal/Modal.svelte | 600 + packages/get-starknet/src/modal/index.ts | 86 + packages/get-starknet/src/vite-env.d.ts | 2 + packages/get-starknet/svelte.config.js | 11 + packages/get-starknet/tailwind.config.cjs | 14 + packages/get-starknet/tsconfig.json | 25 +- packages/get-starknet/vite.config.ts | 26 + packages/guardian/package.json | 6 +- .../guardian/src/services/CosignerTypes.ts | 15 +- .../guardian/src/services/GuardianSigner.ts | 2 +- packages/multicall/package.json | 9 +- packages/sessions/package.json | 9 +- packages/sessions/src/account.ts | 2 +- packages/shared/.eslintrc.json | 28 + packages/shared/package.json | 44 + packages/shared/src/assets/tokens.json | 106 + packages/shared/src/cache/ICacheService.ts | 4 + packages/shared/src/cache/browserCache.ts | 67 + packages/shared/src/cache/index.ts | 2 + packages/shared/src/http/DateService.ts | 40 + packages/shared/src/http/HttpService.ts | 92 + packages/shared/src/http/IDateService.ts | 3 + packages/shared/src/http/IHttpService.ts | 21 + packages/shared/src/http/SwrService.ts | 120 + packages/shared/src/http/apiData.ts | 4 + packages/shared/src/http/fetcher.ts | 86 + packages/shared/src/http/index.ts | 9 + packages/shared/src/http/swr.ts | 50 + packages/shared/src/http/time.ts | 31 + packages/shared/src/index.ts | 6 + packages/shared/src/nfts/aspect.model.ts | 53 + packages/shared/src/nfts/aspect.ts | 183 + packages/shared/src/nfts/index.ts | 4 + packages/shared/src/nfts/useNfts.ts | 24 + packages/shared/src/nfts/utils.ts | 34 + packages/shared/src/tokens/balances.ts | 70 + packages/shared/src/tokens/index.ts | 4 + packages/shared/src/tokens/price.ts | 300 + packages/shared/src/tokens/token.ts | 12 + packages/shared/src/tokens/tokenPriceHooks.ts | 68 + .../transactions/aggregatedSimDataTypes.ts | 67 + .../src/transactions/buildTransactions.ts | 56 + .../transactions/findTransfersAndApprovals.ts | 56 + packages/shared/src/transactions/index.ts | 11 + .../transactions/transactionReviewTypes.ts | 139 + .../transactionSimulationTypes.ts | 43 + .../src/transactions/useAggregatedSimData.ts | 360 + .../src/transactions/useBalanceChange.ts | 19 + .../src/transactions/useErc721Transfers.ts | 25 + .../transactions/useMaxAmountTransaction.ts | 31 + .../src/transactions/useTransactionReview.ts | 153 + .../transactions/useTransactionSimulation.ts | 168 + packages/shared/src/utils/addresses.ts | 82 + packages/shared/src/utils/avatarImage.ts | 90 + packages/shared/src/utils/index.ts | 6 + .../shared/src/utils/isContractDeployed.ts | 13 + packages/shared/src/utils/number.ts | 102 + packages/shared/src/utils/parseAmount.ts | 20 + packages/shared/src/utils/transactions.ts | 9 + packages/shared/tsconfig.json | 20 + packages/shared/vite.config.ts | 37 + packages/stack-router/.eslintrc.js | 3 + .../stack-router/example/src/screens/Tabs.tsx | 9 +- packages/stack-router/package.json | 14 +- .../stack-router/src/StackRoutesConfig.tsx | 5 +- packages/stack-router/src/StackScreen.tsx | 6 +- .../stack-router/src/StackScreenContainer.tsx | 20 +- packages/stack-router/tsconfig.json | 2 +- .../.eslintrc.json | 28 + .../.gitignore | 24 + .../Readme.md | 72 + .../package.json | 52 + .../src/main.ts | 204 + .../src/vite-env.d.ts | 1 + .../tsconfig.json | 22 + .../vite.config.ts | 24 + packages/storybook/.babelrc.json | 16 + packages/storybook/.storybook/decorators.tsx | 51 - packages/storybook/.storybook/globalTypes.ts | 4 +- packages/storybook/.storybook/main.ts | 6 +- packages/storybook/.storybook/polyfill.ts | 17 + packages/storybook/.storybook/preview.ts | 7 +- packages/storybook/package.json | 37 +- .../src/decorators/globalDecorators.tsx | 63 + .../src/decorators/routerDecorators.tsx | 10 + .../accountTokens/TokenListItem.stories.tsx | 158 +- .../accounts/AccountActivity.stories.tsx | 29 +- .../AccountListHiddenScreen.stories.tsx | 31 + .../accounts/AccountListItem.stories.tsx | 118 +- .../accounts/AccountListScreen.stories.tsx | 86 + .../AccountListScreenItem.stories.tsx | 36 + .../accounts/AccountNavigationBar.stories.tsx | 44 + .../accounts/AccountScreenEmpty.stories.tsx | 24 + .../accounts/AccountSelect.stories.tsx | 33 +- .../accounts/AddNewAccountScreen.stories.tsx | 44 + .../accounts/AddressBookMenu.stories.tsx | 74 + .../accounts/DeployAccountScreen.stories.tsx | 15 + ...deOrDeleteAccountConfirmScreen.stories.tsx | 28 + .../MigrationDisclaimerScreen.stories.tsx | 12 + .../accounts/PendingTransactions.stories.tsx | 25 +- ...TransactionCallDataBottomSheet.stories.tsx | 19 +- .../TransactionDetailExplorer.stories.tsx | 336 +- .../accounts/TransactionDetailRaw.stories.tsx | 177 +- .../accounts/TransactionDetailWrapped.tsx | 4 +- .../accounts/TransactionListItem.stories.tsx | 305 +- .../accounts/UpgradeScreen.stories.tsx | 15 + .../accounts/UpgradeScreenV4.stories.tsx | 55 + .../accounts/WarningScreen.stories.tsx | 19 + .../actions/AddTokenScreen.stories.tsx | 43 + .../ApproveSignatureScreen.stories.tsx | 50 + .../actions/ApproveTransaction.stories.tsx | 80 - .../ApproveTransactionScreen.stories.tsx | 71 + .../actions/ConfirmScreen.stories.tsx | 55 +- .../actions/ConnectDappScreen.stories.tsx | 27 + .../features/actions/ErrorScreen.stories.tsx | 18 + .../actions/LoadingScreen.stories.tsx | 18 + .../actions/UpgradeScreenV4.stories.tsx | 26 - .../features/actions/__fixtures__/accounts.ts | 49 + .../features/actions/__fixtures__/aspect.json | 166 - .../features/actions/__fixtures__/aspect.ts | 182 + .../actions/__fixtures__/jediswap.json | 255 - .../features/actions/__fixtures__/jediswap.ts | 279 + .../actions/__fixtures__/transfer.json | 89 - .../features/actions/__fixtures__/transfer.ts | 98 + .../CombinedFeeEstimation.stories.tsx | 43 +- .../transaction/FeeEstimation.stories.tsx | 43 +- .../transaction/FeeEstimationText.stories.tsx | 79 +- .../transaction/TokenField.stories.tsx | 73 +- .../TransactionActions.stories.tsx | 22 +- .../transaction/TransactionBanner.stories.tsx | 37 +- .../VerifiedDappBanner.stories.tsx | 15 +- .../src/features/lock/LockScreen.stories.tsx | 22 +- .../networks/NetworkWarningScreen.stories.tsx | 24 +- .../OnboardingDisclaimerScreen.stories.tsx | 17 +- .../OnboardingFinishScreen.stories.tsx | 17 +- .../OnboardingPasswordScreen.stories.tsx | 17 +- .../OnboardingPrivacyScreen.stories.tsx | 26 - ...boardingPrivacyStatementScreen.stories.tsx | 15 + .../OnboardingRestoreBackup.stories.tsx | 21 +- .../OnboardingRestorePassword.stories.tsx | 22 - .../OnboardingRestoreSeed.stories.tsx | 22 - .../OnboardingRestoreSeedScreen.stories.tsx | 15 + .../onboarding/OnboardingScreen.stories.tsx | 24 + .../OnboardingStartScreen.stories.tsx | 17 +- .../OnboardingToastMessage.stories.tsx | 14 + .../recovery/RecoverySetupScreen.stories.tsx | 22 +- .../DappConnectionsSettings.stories.tsx | 35 +- .../settings/SettingsScreen.stories.tsx | 22 +- .../shield/EscapeGuardian.stories.tsx | 56 +- .../shield/EscapeGuardianReady.stories.tsx | 31 +- .../features/shield/EscapeSigner.stories.tsx | 56 +- .../shield/ShieldAccountActivate.stories.tsx | 22 +- .../ShieldAccountDeactivate.stories.tsx | 22 +- .../shield/ShieldBaseActionScreen.stories.tsx | 29 +- .../shield/ShieldBaseEmailScreen.stories.tsx | 31 +- .../shield/ShieldBaseFinishScreen.stories.tsx | 51 +- .../shield/ShieldBaseOTPScreen.stories.tsx | 25 +- .../ShieldValidationErrorScreen.stories.tsx | 45 +- .../StatusMessageBanner.stories.tsx | 40 +- .../StatusMessageFullScreen.stories.tsx | 43 +- .../ReviewFeedbackScreen.stories.tsx | 19 +- .../userReview/ReviewRatingScreen.stories.tsx | 19 +- .../storybook/src/theme/Colors.stories.mdx | 4 +- .../components => theme}/Icons.stories.tsx | 17 +- .../components => theme}/Logos.stories.tsx | 17 +- .../src/theme/Typography.stories.tsx | 15 +- .../src/ui/components/Accordion.stories.tsx | 30 +- .../src/ui/components/Alert.stories.tsx | 34 +- .../src/ui/components/AlertButton.stories.tsx | 37 +- .../src/ui/components/AlertDialog.stories.tsx | 89 +- .../AlertDialogDeprecated.stories.tsx | 59 - .../src/ui/components/Button.stories.tsx | 112 +- .../components/ButtonDeprecated.stories.tsx | 114 +- .../src/ui/components/CellStack.stories.tsx | 17 +- .../ui/components/CopyIconButton.stories.tsx | 24 +- .../src/ui/components/CopyTooltip.stories.tsx | 22 +- .../components/DappContractField.stories.tsx | 43 +- .../src/ui/components/DappIcon.stories.tsx | 45 +- .../ui/components/DapplandBanner.stories.tsx | 23 +- .../ui/components/DetailAccordion.stories.tsx | 5 +- .../src/ui/components/Empty.stories.tsx | 25 +- .../ui/components/ErrorBoundary.stories.tsx | 26 +- .../ui/components/IconsDeprecated.stories.tsx | 74 - .../src/ui/components/Input.stories.tsx | 57 +- .../src/ui/components/InputText.stories.tsx | 42 +- .../ui/components/InputTextArea.stories.tsx | 38 +- .../src/ui/components/List.stories.tsx | 2 +- .../src/ui/components/Menu.stories.tsx | 19 +- .../NavigationContainer.stories.tsx | 25 +- .../src/ui/components/Option.stories.tsx | 78 +- .../ui/components/PinInputField.stories.tsx | 101 +- .../PrettyAccountAddress.stories.tsx | 51 +- .../src/ui/components/TextArea.stories.tsx | 43 +- .../components/TextAreaDeprecated.stories.tsx | 26 +- packages/swap/.eslintrc.js | 3 + packages/swap/package.json | 8 +- packages/swap/src/lib/hooks/Trade.ts | 4 +- packages/swap/src/lib/state/swap.ts | 2 +- packages/swap/src/lib/state/user.ts | 2 +- packages/swap/src/tokenlist/types.ts | 12 +- packages/swap/tsconfig.json | 2 +- packages/ui/.eslintrc.js | 4 + packages/ui/package.json | 33 +- packages/ui/scripts/generate-icons.ts | 8 +- packages/ui/src/components/ActivityRow.tsx | 55 + packages/ui/src/components/Alert.tsx | 15 + packages/ui/src/components/AlertDialog.tsx | 56 +- packages/ui/src/components/CellStack.tsx | 21 +- .../components/PasswordStrengthIndicator.tsx | 134 + packages/ui/src/components/PinInput.tsx | 9 +- packages/ui/src/components/PreBox.tsx | 37 + packages/ui/src/components/Progress.tsx | 17 + packages/ui/src/components/RoundButton.tsx | 17 + packages/ui/src/components/SplitProgress.tsx | 30 + packages/ui/src/components/StickyGroup.tsx | 15 + packages/ui/src/components/TabBar.tsx | 18 +- .../src/components/TextWithAmount/index.tsx | 6 +- packages/ui/src/components/TokenButton.tsx | 55 +- packages/ui/src/components/TokenIcon.tsx | 38 + packages/ui/src/components/Tooltip.tsx | 42 +- .../components/fees/CombinedFeeEstimation.tsx | 213 + .../src/components/fees/FeeEstimateError.tsx | 90 + .../ui/src/components/fees/FeeEstimation.tsx | 178 + packages/ui/src/components/fees/index.ts | 3 + packages/ui/src/components/fees/types.ts | 23 + packages/ui/src/components/fees/utils.tsx | 114 + .../ui/src/components/icons/AddCircleIcon.tsx | 18 + .../ui/src/components/icons/AddressIcon.tsx | 20 + .../ui/src/components/icons/AlertFillIcon.tsx | 18 + packages/ui/src/components/icons/BinIcon.tsx | 24 + .../ui/src/components/icons/InfoFillIcon.tsx | 18 + packages/ui/src/components/icons/QrIcon.tsx | 30 + packages/ui/src/components/icons/index.ts | 6 + packages/ui/src/components/index.ts | 21 +- .../ui/src/components/logos/ArgentLogo.tsx | 26 + .../ui/src/components/logos/ArgentXLogo.tsx | 2 +- .../src/components/logos/ArgentXLogoFull.tsx | 4 +- packages/ui/src/components/logos/Aspect.tsx | 2 +- packages/ui/src/components/logos/Briq.tsx | 2 +- packages/ui/src/components/logos/Coinbase.tsx | 4 +- packages/ui/src/components/logos/Discord.tsx | 2 +- packages/ui/src/components/logos/Ethereum.tsx | 4 +- packages/ui/src/components/logos/Github.tsx | 2 +- .../ui/src/components/logos/Influence.tsx | 4 +- packages/ui/src/components/logos/Jediswap.tsx | 2 +- packages/ui/src/components/logos/Ledger.tsx | 2 +- .../ui/src/components/logos/Mintsquare.tsx | 4 +- .../src/components/logos/MultisigDiagram.tsx | 47 + packages/ui/src/components/logos/Starknet.tsx | 12 +- packages/ui/src/components/logos/Twitter.tsx | 2 +- packages/ui/src/components/logos/index.ts | 2 + .../transactions/AccountNetworkInfo.tsx | 71 + .../transactions/BalanceChangeOverview.tsx | 377 + .../components/transactions/DappHeader.tsx | 77 + .../components/transactions/NftDetails.tsx | 151 + .../transactions/PrettyAccountAddress.tsx | 57 + .../transactions/TransactionActions.tsx | 151 + .../transactions/TransactionBanner.tsx | 45 + .../DeclareTransactionIcon.tsx | 14 + .../TransactionIcon/IconWrapper.tsx | 22 + .../TransactionIcon/NftTransactionIcon.tsx | 77 + .../TransactionIcon/SendTransactionIcon.tsx | 63 + .../TransactionIcon/SwapTransactionIcon.tsx | 56 + .../TransactionIcon/UnknownDappIcon.tsx | 10 + .../TransactionIcon/UnknownTokenIcon.tsx | 12 + .../TransactionIcon/VerifiedDappIcon.tsx | 9 + .../transactions/TransactionIcon/index.tsx | 69 + .../transactions/TransactionTitle.tsx | 132 + .../transactions/VerifiedDappBanner.tsx | 46 + .../transactions/VerifiedDappModal.tsx | 84 + .../ui/src/components/transactions/index.ts | 8 + packages/ui/src/hooks/index.ts | 1 + packages/ui/src/hooks/usePasswordStrength.ts | 29 + packages/ui/src/hooks/useToast.tsx | 3 +- packages/ui/src/theme/colors.ts | 3 + packages/ui/src/theme/index.tsx | 2 + packages/ui/src/theme/shadows.ts | 1 + packages/ui/src/theme/spacing.ts | 5 + packages/ui/src/theme/typography.ts | 1 + packages/ui/tsconfig.json | 8 +- packages/ui/vite.config.ts | 6 + packages/web-sdk/.eslintrc.json | 28 + packages/web-sdk/.gitignore | 24 + packages/web-sdk/package.json | 52 + packages/web-sdk/src/main.ts | 86 + packages/web-sdk/src/vite-env.d.ts | 1 + packages/web-sdk/src/wormhole.ts | 321 + packages/web-sdk/tsconfig.json | 22 + packages/web-sdk/vite.config.ts | 24 + packages/window/.eslintrc.js | 3 + packages/window/package.json | 7 +- packages/window/src/account.ts | 4 +- packages/window/src/index.ts | 8 +- .../messages/__tests__/bidirectional.test.ts | 98 - .../src/messages/exchange/bidirectional.ts | 107 +- .../window/src/messages/messenger/window.ts | 23 +- packages/window/src/starknet.ts | 27 +- packages/window/src/types.ts | 11 +- packages/window/tsconfig.json | 2 +- patches/@noble+hashes+1.3.0.patch | 39 + ...net+4.21.0.patch => starknet+4.22.0.patch} | 8 +- scripts/update-starknet-js.sh | 45 +- sonar-project.properties | 4 - yarn.lock | 13254 +++++++--------- 899 files changed, 36458 insertions(+), 19273 deletions(-) create mode 100644 .vscode/extensions.json rename packages/{extension => }/e2e/.eslintrc.js (100%) rename packages/{extension => }/e2e/.gitignore (100%) rename packages/{extension => }/e2e/Dockerfile (100%) rename packages/{extension/e2e => e2e/extension}/network-setup/Dockerfile (100%) rename packages/{extension/e2e => e2e/extension}/network-setup/build_and_push.sh (100%) rename packages/{extension/e2e => e2e/extension}/network-setup/dump.pkl (100%) rename packages/{extension/e2e => e2e/extension}/src/config.ts (85%) rename packages/{extension/e2e => e2e/extension}/src/fixtures.ts (100%) rename packages/{extension/e2e => e2e/extension}/src/languages/ILanguage.ts (98%) rename packages/{extension/e2e => e2e/extension}/src/languages/en/index.ts (98%) rename packages/{extension/e2e => e2e/extension}/src/languages/index.ts (99%) rename packages/{extension/e2e => e2e/extension}/src/page-objects/Account.ts (91%) rename packages/{extension/e2e => e2e/extension}/src/page-objects/Activity.ts (100%) rename packages/{extension/e2e => e2e/extension}/src/page-objects/AddressBook.ts (100%) rename packages/{extension/e2e => e2e/extension}/src/page-objects/DeveloperSettings.ts (100%) rename packages/{extension/e2e => e2e/extension}/src/page-objects/ExtensionPage.ts (100%) rename packages/{extension/e2e => e2e/extension}/src/page-objects/Navigation.ts (100%) rename packages/{extension/e2e => e2e/extension}/src/page-objects/Network.ts (100%) rename packages/{extension/e2e => e2e/extension}/src/page-objects/Settings.ts (97%) rename packages/{extension/e2e => e2e/extension}/src/page-objects/Wallet.ts (85%) rename packages/{extension/e2e => e2e/extension}/src/specs/accountSettings.spec.ts (100%) rename packages/{extension/e2e => e2e/extension}/src/specs/addressBook.spec.ts (100%) rename packages/{extension/e2e => e2e/extension}/src/specs/dappsBanner.spec.ts (100%) rename packages/{extension/e2e => e2e/extension}/src/specs/links.spec.ts (100%) rename packages/{extension/e2e => e2e/extension}/src/specs/network.spec.ts (98%) rename packages/{extension/e2e => e2e/extension}/src/specs/receiveFunds.spec.ts (100%) rename packages/{extension/e2e => e2e/extension}/src/specs/recovery.spec.ts (100%) create mode 100644 packages/e2e/extension/src/specs/sendFundsMax.spec.ts rename packages/{extension/e2e/src/specs/sendFunds.spec.ts => e2e/extension/src/specs/sendFundsPartial.spec.ts} (60%) rename packages/{extension/e2e => e2e/extension}/src/specs/welcome.spec.ts (100%) rename packages/{extension/e2e => e2e/extension}/src/test.ts (98%) rename packages/{extension/e2e => e2e/extension}/src/utils/Messages.ts (100%) create mode 100644 packages/e2e/package.json rename packages/{extension => e2e}/playwright.config.ts (87%) rename packages/{extension => }/e2e/tsconfig.json (80%) create mode 100644 packages/extension/sonar-project.properties create mode 100644 packages/extension/src/background/__new/middleware/analytics.ts create mode 100644 packages/extension/src/background/__new/middleware/session.ts create mode 100644 packages/extension/src/background/__new/procedures/account/create.ts create mode 100644 packages/extension/src/background/__new/procedures/account/deploy.ts create mode 100644 packages/extension/src/background/__new/procedures/account/index.ts create mode 100644 packages/extension/src/background/__new/procedures/account/upgrade.ts create mode 100644 packages/extension/src/background/__new/procedures/network/add.ts create mode 100644 packages/extension/src/background/__new/procedures/network/index.ts create mode 100644 packages/extension/src/background/__new/procedures/permissions.ts create mode 100644 packages/extension/src/background/__new/procedures/recovery/index.ts create mode 100644 packages/extension/src/background/__new/procedures/recovery/recoverBackup.ts create mode 100644 packages/extension/src/background/__new/procedures/recovery/recoverSeedphrase.ts create mode 100644 packages/extension/src/background/__new/router.ts create mode 100644 packages/extension/src/background/__new/services/onboarding/implementation.test.ts create mode 100644 packages/extension/src/background/__new/services/onboarding/implementation.ts create mode 100644 packages/extension/src/background/__new/services/onboarding/index.ts create mode 100644 packages/extension/src/background/__new/services/onboarding/interface.ts create mode 100644 packages/extension/src/background/__new/services/onboarding/worker/implementation.test.ts create mode 100644 packages/extension/src/background/__new/services/onboarding/worker/implementation.ts create mode 100644 packages/extension/src/background/__new/trpc.ts create mode 100644 packages/extension/src/background/multisigDeployAction.ts create mode 100644 packages/extension/src/background/multisigMessaging.ts create mode 100644 packages/extension/src/background/network/network.service.ts rename packages/extension/src/background/{networkStatus.ts => network/networkStatus.worker.ts} (94%) delete mode 100644 packages/extension/src/background/recoveryMessaging.ts create mode 100644 packages/extension/src/background/transactions/fees/multisigFeeEstimation.ts create mode 100644 packages/extension/src/background/transactions/onupdate/multisigUpdates.ts create mode 100644 packages/extension/src/background/walletSingleton.ts create mode 100644 packages/extension/src/inpage/trpcClient.ts create mode 100644 packages/extension/src/shared/__new/services/ui/implementation.test.ts create mode 100644 packages/extension/src/shared/__new/services/ui/implementation.ts create mode 100644 packages/extension/src/shared/__new/services/ui/index.ts create mode 100644 packages/extension/src/shared/__new/services/ui/interface.ts create mode 100644 packages/extension/src/shared/account/details/updateAccountsWithNames.ts create mode 100644 packages/extension/src/shared/account/service/implementation.test.ts create mode 100644 packages/extension/src/shared/account/service/implementation.ts create mode 100644 packages/extension/src/shared/account/service/index.ts create mode 100644 packages/extension/src/shared/account/service/interface.ts delete mode 100644 packages/extension/src/shared/account/store.ts create mode 100644 packages/extension/src/shared/account/store/index.ts create mode 100644 packages/extension/src/shared/account/store/serialize.test.ts rename packages/extension/src/shared/account/{ => store}/serialize.ts (81%) create mode 100644 packages/extension/src/shared/call/changeMultisigSignersCall.ts create mode 100644 packages/extension/src/shared/call/setMultisigThresholdCalls.ts create mode 100644 packages/extension/src/shared/messages/MultisigMessage.ts delete mode 100644 packages/extension/src/shared/messages/RecoveryMessage.ts create mode 100644 packages/extension/src/shared/multisig/account.ts create mode 100644 packages/extension/src/shared/multisig/mocks/executeTransaction.mock.ts create mode 100644 packages/extension/src/shared/multisig/multisig.model.ts create mode 100644 packages/extension/src/shared/multisig/multisig.service.ts create mode 100644 packages/extension/src/shared/multisig/pendingTransactionsStore.ts create mode 100644 packages/extension/src/shared/multisig/signer.ts create mode 100644 packages/extension/src/shared/multisig/store.ts create mode 100644 packages/extension/src/shared/multisig/tracking.ts create mode 100644 packages/extension/src/shared/multisig/types.ts create mode 100644 packages/extension/src/shared/multisig/utils/baseMultisig.ts create mode 100644 packages/extension/src/shared/multisig/utils/pendingMultisig.ts create mode 100644 packages/extension/src/shared/multisig/utils/selectors.ts create mode 100644 packages/extension/src/shared/network/service/implementation.ts create mode 100644 packages/extension/src/shared/network/service/interface.ts create mode 100644 packages/extension/src/shared/network/view/index.ts create mode 100644 packages/extension/src/shared/schemas/address.ts create mode 100644 packages/extension/src/shared/schemas/hex.ts create mode 100644 packages/extension/src/shared/schemas/seedphrase.ts create mode 100644 packages/extension/src/shared/storage/__new/__test__/inmemoryImplementations.test.ts create mode 100644 packages/extension/src/shared/storage/__new/__test__/inmemoryImplementations.ts create mode 100644 packages/extension/src/shared/storage/__new/__test__/keyvalue.test.ts create mode 100644 packages/extension/src/shared/storage/__new/__test__/mockFunctionImplementation.ts create mode 100644 packages/extension/src/shared/storage/__new/interface.ts create mode 100644 packages/extension/src/shared/storage/__new/keyvalue.ts create mode 100644 packages/extension/src/shared/storage/__new/object.ts create mode 100644 packages/extension/src/shared/storage/__new/repositories/network.ts create mode 100644 packages/extension/src/shared/storage/__new/repository.ts create mode 100644 packages/extension/src/shared/types/deepPick.ts create mode 100644 packages/extension/src/shared/utils/accountsMultisigSort.ts create mode 100644 packages/extension/src/shared/utils/encode.ts create mode 100644 packages/extension/src/shared/utils/starknetNetwork.ts create mode 100644 packages/extension/src/ui/components/ControlledInput.tsx delete mode 100644 packages/extension/src/ui/components/PrivacyStatementLink.tsx create mode 100644 packages/extension/src/ui/features/accountActivity/PendingMultisigTransactions.tsx create mode 100644 packages/extension/src/ui/features/accountActivity/transform/transaction/transformers/changeMultisigThresholdTransformer.ts create mode 100644 packages/extension/src/ui/features/accountActivity/transform/transaction/transformers/changeMultisigTransformer.ts create mode 100644 packages/extension/src/ui/features/accountActivity/transform/transaction/transformers/pendingMultisigTransactionAdapter.ts create mode 100644 packages/extension/src/ui/features/accountEdit/AccountEditButtons.tsx create mode 100644 packages/extension/src/ui/features/accountEdit/AccountEditButtonsMultisig.tsx create mode 100644 packages/extension/src/ui/features/accountTokens/ActivateMultisigBanner.tsx create mode 100644 packages/extension/src/ui/features/accounts/AccountAvatar.tsx create mode 100644 packages/extension/src/ui/features/accounts/AccountLabel.tsx create mode 100644 packages/extension/src/ui/features/accounts/AccountListHiddenScreen.test.tsx create mode 100644 packages/extension/src/ui/features/accounts/AccountListHiddenScreenContainer.tsx delete mode 100644 packages/extension/src/ui/features/accounts/AccountListHiddenScreenItem.tsx create mode 100644 packages/extension/src/ui/features/accounts/AccountListItemShieldBadge.tsx create mode 100644 packages/extension/src/ui/features/accounts/AccountListItemShieldBadgeContainer.tsx create mode 100644 packages/extension/src/ui/features/accounts/AccountListItemUpgradeBadge.tsx create mode 100644 packages/extension/src/ui/features/accounts/AccountListScreenContainer.tsx create mode 100644 packages/extension/src/ui/features/accounts/AccountListScreenItem.test.tsx create mode 100644 packages/extension/src/ui/features/accounts/AccountListScreenItemAccessory.tsx create mode 100644 packages/extension/src/ui/features/accounts/AccountListScreenItemContainer.tsx create mode 100644 packages/extension/src/ui/features/accounts/AccountNavigationBar.test.tsx create mode 100644 packages/extension/src/ui/features/accounts/AccountNavigationBarContainer.tsx create mode 100644 packages/extension/src/ui/features/accounts/AccountScreenEmpty.test.tsx create mode 100644 packages/extension/src/ui/features/accounts/AccountScreenEmptyContainer.tsx create mode 100644 packages/extension/src/ui/features/accounts/AddNewAccountScreen.test.tsx create mode 100644 packages/extension/src/ui/features/accounts/AddNewAccountScreenContainer.tsx create mode 100644 packages/extension/src/ui/features/accounts/AddressBookMenu.test.tsx delete mode 100644 packages/extension/src/ui/features/accounts/DeployAccount.tsx create mode 100644 packages/extension/src/ui/features/accounts/DeployAccountScreen.test.tsx create mode 100644 packages/extension/src/ui/features/accounts/DeployAccountScreen.tsx create mode 100644 packages/extension/src/ui/features/accounts/DeployAccountScreenContainer.tsx delete mode 100644 packages/extension/src/ui/features/accounts/DeprecatedAccountScreen.tsx create mode 100644 packages/extension/src/ui/features/accounts/GroupedAccountList.tsx create mode 100644 packages/extension/src/ui/features/accounts/HiddenAccountsBarContainer.tsx create mode 100644 packages/extension/src/ui/features/accounts/HideOrDeleteAccountConfirmScreen.test.tsx create mode 100644 packages/extension/src/ui/features/accounts/HideOrDeleteAccountConfirmScreenContainer.tsx create mode 100644 packages/extension/src/ui/features/accounts/MigrationDisclaimerScreen.test.tsx create mode 100644 packages/extension/src/ui/features/accounts/MigrationDisclaimerScreen.tsx create mode 100644 packages/extension/src/ui/features/accounts/MigrationDisclaimerScreenContainer.tsx delete mode 100644 packages/extension/src/ui/features/accounts/ProfilePicture.tsx delete mode 100644 packages/extension/src/ui/features/accounts/SectionHeader.tsx create mode 100644 packages/extension/src/ui/features/accounts/UpgradeScreen.test.tsx create mode 100644 packages/extension/src/ui/features/accounts/UpgradeScreenContainer.tsx create mode 100644 packages/extension/src/ui/features/accounts/UpgradeScreenV4.test.tsx create mode 100644 packages/extension/src/ui/features/accounts/UpgradeScreenV4Container.tsx create mode 100644 packages/extension/src/ui/features/accounts/WarningScreen.tsx create mode 100644 packages/extension/src/ui/features/accounts/accountMetadata.test.ts create mode 100644 packages/extension/src/ui/features/accounts/ui/StarknetAccountMessage.tsx delete mode 100644 packages/extension/src/ui/features/accounts/useAddAccount.ts create mode 100644 packages/extension/src/ui/features/accounts/usePublicKey.ts delete mode 100644 packages/extension/src/ui/features/actions/AddNetworkScreen.tsx create mode 100644 packages/extension/src/ui/features/actions/AddNetworkScreen/AddNetworkScreen.test.tsx create mode 100644 packages/extension/src/ui/features/actions/AddNetworkScreen/AddNetworkScreen.tsx create mode 100644 packages/extension/src/ui/features/actions/AddNetworkScreen/AddNetworkScreenContainer.tsx create mode 100644 packages/extension/src/ui/features/actions/AddTokenActionScreenContainer.tsx create mode 100644 packages/extension/src/ui/features/actions/AddTokenScreenContainer.tsx delete mode 100644 packages/extension/src/ui/features/actions/ApproveDeclareContractScreen.tsx delete mode 100644 packages/extension/src/ui/features/actions/ApproveDeployContractScreen.tsx create mode 100644 packages/extension/src/ui/features/actions/ApproveSignatureScreenContainer.tsx create mode 100644 packages/extension/src/ui/features/actions/DeclareContractActionScreenContainer.tsx create mode 100644 packages/extension/src/ui/features/actions/DeployAccountActionScreenContainer.tsx create mode 100644 packages/extension/src/ui/features/actions/DeployContractActionScreenContainer.tsx create mode 100644 packages/extension/src/ui/features/actions/DeployMultisigActionScreenContainer.tsx create mode 100644 packages/extension/src/ui/features/actions/ErrorScreenContainer.tsx create mode 100644 packages/extension/src/ui/features/actions/LoadingScreenContainer.tsx create mode 100644 packages/extension/src/ui/features/actions/SignActionScreenContainer.tsx create mode 100644 packages/extension/src/ui/features/actions/TransactionActionScreenContainer.tsx delete mode 100644 packages/extension/src/ui/features/actions/connectDapp/ConnectDappAccountListItem.tsx create mode 100644 packages/extension/src/ui/features/actions/connectDapp/ConnectDappAccountSelect.tsx create mode 100644 packages/extension/src/ui/features/actions/connectDapp/ConnectDappScreenContainer.tsx delete mode 100644 packages/extension/src/ui/features/actions/feeEstimation/AccountDeploymentFeeEstimation.tsx create mode 100644 packages/extension/src/ui/features/actions/feeEstimation/CombinedFeeEstimationContainer.tsx delete mode 100644 packages/extension/src/ui/features/actions/feeEstimation/DeclareContractFeeEstimation.tsx delete mode 100644 packages/extension/src/ui/features/actions/feeEstimation/DeclareDeploy/Estimation.tsx delete mode 100644 packages/extension/src/ui/features/actions/feeEstimation/DeclareDeploy/NetworkFee.tsx delete mode 100644 packages/extension/src/ui/features/actions/feeEstimation/DeclareDeploy/TokenAmounts.tsx delete mode 100644 packages/extension/src/ui/features/actions/feeEstimation/DeclareDeploy/useEstimationAccountFees.tsx create mode 100644 packages/extension/src/ui/features/actions/feeEstimation/DeployAccountFeeEstimation.tsx delete mode 100644 packages/extension/src/ui/features/actions/feeEstimation/DeployContractFeeEstimation.tsx create mode 100644 packages/extension/src/ui/features/actions/feeEstimation/FeeEstimationContainer.tsx delete mode 100644 packages/extension/src/ui/features/actions/feeEstimation/styled.tsx create mode 100644 packages/extension/src/ui/features/actions/hooks/useActionScreen.ts create mode 100644 packages/extension/src/ui/features/actions/transaction/ApproveDeployMultisig.tsx create mode 100644 packages/extension/src/ui/features/actions/transaction/ApproveTransactionScreen/ApproveTransactionScreen.tsx create mode 100644 packages/extension/src/ui/features/actions/transaction/ApproveTransactionScreen/ApproveTransactionScreenContainer.tsx create mode 100644 packages/extension/src/ui/features/actions/transaction/ApproveTransactionScreen/DappHeader/TransactionIcon/ActivateAccountIcon.tsx create mode 100644 packages/extension/src/ui/features/actions/transaction/ApproveTransactionScreen/DappHeader/TransactionIcon/ActivateMultisigIcon.tsx create mode 100644 packages/extension/src/ui/features/actions/transaction/ApproveTransactionScreen/DappHeader/TransactionIcon/AddArgentShieldIcon.tsx create mode 100644 packages/extension/src/ui/features/actions/transaction/ApproveTransactionScreen/DappHeader/TransactionIcon/AddOwnerIcon.tsx create mode 100644 packages/extension/src/ui/features/actions/transaction/ApproveTransactionScreen/DappHeader/TransactionIcon/RemoveArgentShieldIcon.tsx create mode 100644 packages/extension/src/ui/features/actions/transaction/ApproveTransactionScreen/DappHeader/TransactionIcon/RemoveOwnerIcon.tsx create mode 100644 packages/extension/src/ui/features/actions/transaction/ApproveTransactionScreen/DappHeader/TransactionIcon/UpdateThresholdIcon.tsx create mode 100644 packages/extension/src/ui/features/actions/transaction/ApproveTransactionScreen/MultisigBanner.tsx delete mode 100644 packages/extension/src/ui/features/actions/transaction/ApproveTransactionScreen/index.tsx delete mode 100644 packages/extension/src/ui/features/actions/transaction/DefaultTransactionDetails.tsx delete mode 100644 packages/extension/src/ui/features/actions/transaction/ERC20ApproveTransactionDetails.tsx delete mode 100644 packages/extension/src/ui/features/actions/transaction/ERC20TransferTransactionDetails.tsx delete mode 100644 packages/extension/src/ui/features/actions/transaction/TransactionDetails.tsx delete mode 100644 packages/extension/src/ui/features/actions/transaction/TransactionsListSwap.tsx create mode 100644 packages/extension/src/ui/features/actions/transaction/fields/MaybeDappContractField.tsx create mode 100644 packages/extension/src/ui/features/actions/transaction/types.ts create mode 100644 packages/extension/src/ui/features/actions/utils.ts create mode 100644 packages/extension/src/ui/features/multisig/AddOwnerForm.tsx create mode 100644 packages/extension/src/ui/features/multisig/CreateMultisigScreen/CreateMultisigStartScreen.tsx create mode 100644 packages/extension/src/ui/features/multisig/CreateMultisigScreen/MultisigFirstStep.test.tsx create mode 100644 packages/extension/src/ui/features/multisig/CreateMultisigScreen/MultisigFirstStep.tsx create mode 100644 packages/extension/src/ui/features/multisig/CreateMultisigScreen/MultisigSecondStep.tsx create mode 100644 packages/extension/src/ui/features/multisig/CreateMultisigScreen/MultisigThirdStep.tsx create mode 100644 packages/extension/src/ui/features/multisig/CreateMultisigScreen/ScreenLayout.tsx create mode 100644 packages/extension/src/ui/features/multisig/JoinMultisigScreen.tsx create mode 100644 packages/extension/src/ui/features/multisig/JoinMultisigSettingsScreen.tsx create mode 100644 packages/extension/src/ui/features/multisig/Multisig.ts create mode 100644 packages/extension/src/ui/features/multisig/MultisigAddOwnersScreen.tsx create mode 100644 packages/extension/src/ui/features/multisig/MultisigBanner.tsx create mode 100644 packages/extension/src/ui/features/multisig/MultisigConfirmationsScreen.tsx create mode 100644 packages/extension/src/ui/features/multisig/MultisigDeleteModal.tsx create mode 100644 packages/extension/src/ui/features/multisig/MultisigListAccounts.tsx create mode 100644 packages/extension/src/ui/features/multisig/MultisigOwnersScreen.tsx create mode 100644 packages/extension/src/ui/features/multisig/MultisigPendingTransactionDetailsScreen.tsx create mode 100644 packages/extension/src/ui/features/multisig/MultisigPendingTxModal.tsx create mode 100644 packages/extension/src/ui/features/multisig/MultisigRemoveOwnerScreen.tsx create mode 100644 packages/extension/src/ui/features/multisig/MultisigSettingsWrapper.tsx create mode 100644 packages/extension/src/ui/features/multisig/MultisigTransactionConfirmationsScreen.tsx create mode 100644 packages/extension/src/ui/features/multisig/NewMultisigScreen.tsx create mode 100644 packages/extension/src/ui/features/multisig/PendingMultisigListItem.tsx create mode 100644 packages/extension/src/ui/features/multisig/PendingMultisigListScreenItem.tsx create mode 100644 packages/extension/src/ui/features/multisig/RemoveMultisigSettingScreen.test.tsx create mode 100644 packages/extension/src/ui/features/multisig/RemovedMultisigSettingsScreen.tsx create mode 100644 packages/extension/src/ui/features/multisig/RemovedMultisigSettingsScreenContainer.tsx create mode 100644 packages/extension/src/ui/features/multisig/RemovedMultisigWarningScreen.tsx create mode 100644 packages/extension/src/ui/features/multisig/SetConfirmationsInput.tsx create mode 100644 packages/extension/src/ui/features/multisig/hooks/useCreateMultisigForm.ts create mode 100644 packages/extension/src/ui/features/multisig/hooks/useCreatePendingMultisig.ts create mode 100644 packages/extension/src/ui/features/multisig/hooks/useIsMultisigDeploying.ts create mode 100644 packages/extension/src/ui/features/multisig/hooks/useIsSignerInMultisig.ts create mode 100644 packages/extension/src/ui/features/multisig/hooks/useMultisigDataforAccount.ts create mode 100644 packages/extension/src/ui/features/multisig/hooks/useUpdateThreshold.ts create mode 100644 packages/extension/src/ui/features/multisig/multisig.mock.ts create mode 100644 packages/extension/src/ui/features/multisig/multisig.state.ts create mode 100644 packages/extension/src/ui/features/multisig/multisigTransactions.state.ts delete mode 100644 packages/extension/src/ui/features/networks/NetworkSwitcher.tsx create mode 100644 packages/extension/src/ui/features/networks/NetworkSwitcher/NetworkSwitcher.test.tsx create mode 100644 packages/extension/src/ui/features/networks/NetworkSwitcher/NetworkSwitcherButton.tsx create mode 100644 packages/extension/src/ui/features/networks/NetworkSwitcher/NetworkSwitcherContainer.tsx create mode 100644 packages/extension/src/ui/features/networks/NetworkSwitcher/NetworkSwitcherList.tsx create mode 100644 packages/extension/src/ui/features/networks/NetworkWarningScreen/NetworkWarningScreen.test.tsx rename packages/extension/src/ui/features/networks/{ => NetworkWarningScreen}/NetworkWarningScreen.tsx (67%) create mode 100644 packages/extension/src/ui/features/networks/NetworkWarningScreen/NetworkWarningScreenContainer.tsx create mode 100644 packages/extension/src/ui/features/networks/hooks/useCurrentNetwork.ts create mode 100644 packages/extension/src/ui/features/networks/hooks/useIsMainnet.ts rename packages/extension/src/ui/features/networks/{seenNetworkStatusWarning.state.ts => hooks/useNeedsToShowNetworkStatusWarning.ts} (85%) create mode 100644 packages/extension/src/ui/features/networks/hooks/useNetwork.ts create mode 100644 packages/extension/src/ui/features/networks/hooks/useNetworks.ts rename packages/extension/src/ui/features/networks/{showNetworkUpgrade.ts => hooks/useShouldShowNetworkUpgradeMessage.ts} (92%) delete mode 100644 packages/extension/src/ui/features/networks/useNetworks.ts delete mode 100644 packages/extension/src/ui/features/onboarding/MigrationDisclaimerScreen.tsx create mode 100644 packages/extension/src/ui/features/onboarding/OnboardingDisclaimerScreen.test.tsx create mode 100644 packages/extension/src/ui/features/onboarding/OnboardingDisclaimerScreenContainer.tsx create mode 100644 packages/extension/src/ui/features/onboarding/OnboardingFinishScreen.test.tsx create mode 100644 packages/extension/src/ui/features/onboarding/OnboardingFinishScreenContainer.tsx create mode 100644 packages/extension/src/ui/features/onboarding/OnboardingPasswordScreen.test.tsx create mode 100644 packages/extension/src/ui/features/onboarding/OnboardingPasswordScreenContainer.tsx create mode 100644 packages/extension/src/ui/features/onboarding/OnboardingPrivacyStatementScreen.test.tsx create mode 100644 packages/extension/src/ui/features/onboarding/OnboardingPrivacyStatementScreenContainer.tsx delete mode 100644 packages/extension/src/ui/features/onboarding/OnboardingRestoreBackup.tsx create mode 100644 packages/extension/src/ui/features/onboarding/OnboardingRestoreBackupScreen.test.tsx create mode 100644 packages/extension/src/ui/features/onboarding/OnboardingRestoreBackupScreen.tsx create mode 100644 packages/extension/src/ui/features/onboarding/OnboardingRestoreBackupScreenContainer.tsx delete mode 100644 packages/extension/src/ui/features/onboarding/OnboardingRestorePassword.tsx create mode 100644 packages/extension/src/ui/features/onboarding/OnboardingRestorePasswordScreenContainer.tsx delete mode 100644 packages/extension/src/ui/features/onboarding/OnboardingRestoreSeed.tsx create mode 100644 packages/extension/src/ui/features/onboarding/OnboardingRestoreSeedScreen.test.tsx create mode 100644 packages/extension/src/ui/features/onboarding/OnboardingRestoreSeedScreen.tsx create mode 100644 packages/extension/src/ui/features/onboarding/OnboardingRestoreSeedScreenContainer.tsx create mode 100644 packages/extension/src/ui/features/onboarding/OnboardingStartScreen.test.tsx create mode 100644 packages/extension/src/ui/features/onboarding/OnboardingStartScreenContainer.tsx delete mode 100644 packages/extension/src/ui/features/onboarding/StickyArgentFooter.tsx create mode 100644 packages/extension/src/ui/features/onboarding/hooks/useOnboardingScreen.ts create mode 100644 packages/extension/src/ui/features/onboarding/hooks/useOnboardingToastMessage.tsx create mode 100644 packages/extension/src/ui/features/onboarding/ui/OnboardingCheckbox.tsx create mode 100644 packages/extension/src/ui/features/onboarding/ui/OnboardingRectButton.tsx create mode 100644 packages/extension/src/ui/features/onboarding/ui/OnboardingToastMessage.tsx delete mode 100644 packages/extension/src/ui/features/onboarding/ui/RectButton.tsx delete mode 100644 packages/extension/src/ui/features/recovery/BackupDownloadScreen.tsx rename packages/extension/src/ui/features/recovery/{ => hooks}/useCustomNavigate.ts (84%) rename packages/extension/src/ui/features/recovery/{ => hooks}/useSeedPhrase.ts (81%) create mode 100644 packages/extension/src/ui/features/recovery/seedRecovery.state.test.ts create mode 100644 packages/extension/src/ui/features/recovery/ui/CircleIconContainer.tsx create mode 100644 packages/extension/src/ui/features/recovery/ui/ComingSoonIcon.tsx create mode 100644 packages/extension/src/ui/features/recovery/ui/CopySeedPhraseButton.tsx create mode 100644 packages/extension/src/ui/features/recovery/ui/LoadingSeedWordBadge.tsx create mode 100644 packages/extension/src/ui/features/recovery/ui/SeedPhraseGrid.tsx create mode 100644 packages/extension/src/ui/features/recovery/ui/SeedWordBadge.tsx create mode 100644 packages/extension/src/ui/features/recovery/ui/SeedWordBadgeNumber.tsx create mode 100644 packages/extension/src/ui/features/recovery/ui/WarningText.tsx create mode 100644 packages/extension/src/ui/hooks/useAction.ts create mode 100644 packages/extension/src/ui/hooks/useNavigateReturnTo.ts create mode 100644 packages/extension/src/ui/services/account/clientTrpc.ts create mode 100644 packages/extension/src/ui/services/account/index.ts create mode 100644 packages/extension/src/ui/services/account/interface.ts create mode 100644 packages/extension/src/ui/services/backgroundMultisigs.ts delete mode 100644 packages/extension/src/ui/services/backgroundNetworks.ts delete mode 100644 packages/extension/src/ui/services/backgroundRecovery.ts create mode 100644 packages/extension/src/ui/services/extension/clientLegacy.ts create mode 100644 packages/extension/src/ui/services/extension/index.ts create mode 100644 packages/extension/src/ui/services/extension/interface.ts create mode 100644 packages/extension/src/ui/services/messaging/trpc.ts create mode 100644 packages/extension/src/ui/services/recovery/implementation.ts create mode 100644 packages/extension/src/ui/services/recovery/index.ts create mode 100644 packages/extension/src/ui/services/recovery/interface.ts create mode 100644 packages/extension/src/ui/test/utils.tsx create mode 100644 packages/extension/src/ui/views/account.ts create mode 100644 packages/extension/src/ui/views/defaults.ts create mode 100644 packages/extension/src/ui/views/implementation/atomFromRepo.ts create mode 100644 packages/extension/src/ui/views/implementation/atomFromStore.ts create mode 100644 packages/extension/src/ui/views/implementation/react.ts create mode 100644 packages/extension/test/__mocks__/Svg.mock.tsx create mode 100644 packages/extension/test/account.mock.ts create mode 100644 packages/extension/test/walletAccount.mock.ts create mode 100644 packages/get-starknet/.eslintrc.json create mode 100644 packages/get-starknet/.gitignore create mode 100644 packages/get-starknet/CHANGELOG.md delete mode 100644 packages/get-starknet/README.md create mode 100644 packages/get-starknet/postcss.config.cjs delete mode 100644 packages/get-starknet/rollup.config.mjs delete mode 100644 packages/get-starknet/src/index.ts create mode 100644 packages/get-starknet/src/main.ts create mode 100644 packages/get-starknet/src/modal/Modal.svelte create mode 100644 packages/get-starknet/src/modal/index.ts create mode 100644 packages/get-starknet/src/vite-env.d.ts create mode 100644 packages/get-starknet/svelte.config.js create mode 100644 packages/get-starknet/tailwind.config.cjs create mode 100644 packages/get-starknet/vite.config.ts create mode 100644 packages/shared/.eslintrc.json create mode 100644 packages/shared/package.json create mode 100644 packages/shared/src/assets/tokens.json create mode 100644 packages/shared/src/cache/ICacheService.ts create mode 100644 packages/shared/src/cache/browserCache.ts create mode 100644 packages/shared/src/cache/index.ts create mode 100644 packages/shared/src/http/DateService.ts create mode 100644 packages/shared/src/http/HttpService.ts create mode 100644 packages/shared/src/http/IDateService.ts create mode 100644 packages/shared/src/http/IHttpService.ts create mode 100644 packages/shared/src/http/SwrService.ts create mode 100644 packages/shared/src/http/apiData.ts create mode 100644 packages/shared/src/http/fetcher.ts create mode 100644 packages/shared/src/http/index.ts create mode 100644 packages/shared/src/http/swr.ts create mode 100644 packages/shared/src/http/time.ts create mode 100644 packages/shared/src/index.ts create mode 100644 packages/shared/src/nfts/aspect.model.ts create mode 100644 packages/shared/src/nfts/aspect.ts create mode 100644 packages/shared/src/nfts/index.ts create mode 100644 packages/shared/src/nfts/useNfts.ts create mode 100644 packages/shared/src/nfts/utils.ts create mode 100644 packages/shared/src/tokens/balances.ts create mode 100644 packages/shared/src/tokens/index.ts create mode 100644 packages/shared/src/tokens/price.ts create mode 100644 packages/shared/src/tokens/token.ts create mode 100644 packages/shared/src/tokens/tokenPriceHooks.ts create mode 100644 packages/shared/src/transactions/aggregatedSimDataTypes.ts create mode 100644 packages/shared/src/transactions/buildTransactions.ts create mode 100644 packages/shared/src/transactions/findTransfersAndApprovals.ts create mode 100644 packages/shared/src/transactions/index.ts create mode 100644 packages/shared/src/transactions/transactionReviewTypes.ts create mode 100644 packages/shared/src/transactions/transactionSimulationTypes.ts create mode 100644 packages/shared/src/transactions/useAggregatedSimData.ts create mode 100644 packages/shared/src/transactions/useBalanceChange.ts create mode 100644 packages/shared/src/transactions/useErc721Transfers.ts create mode 100644 packages/shared/src/transactions/useMaxAmountTransaction.ts create mode 100644 packages/shared/src/transactions/useTransactionReview.ts create mode 100644 packages/shared/src/transactions/useTransactionSimulation.ts create mode 100644 packages/shared/src/utils/addresses.ts create mode 100644 packages/shared/src/utils/avatarImage.ts create mode 100644 packages/shared/src/utils/index.ts create mode 100644 packages/shared/src/utils/isContractDeployed.ts create mode 100644 packages/shared/src/utils/number.ts create mode 100644 packages/shared/src/utils/parseAmount.ts create mode 100644 packages/shared/src/utils/transactions.ts create mode 100644 packages/shared/tsconfig.json create mode 100644 packages/shared/vite.config.ts create mode 100644 packages/starknet-react-webwallet-connector/.eslintrc.json create mode 100644 packages/starknet-react-webwallet-connector/.gitignore create mode 100644 packages/starknet-react-webwallet-connector/Readme.md create mode 100644 packages/starknet-react-webwallet-connector/package.json create mode 100644 packages/starknet-react-webwallet-connector/src/main.ts create mode 100644 packages/starknet-react-webwallet-connector/src/vite-env.d.ts create mode 100644 packages/starknet-react-webwallet-connector/tsconfig.json create mode 100644 packages/starknet-react-webwallet-connector/vite.config.ts create mode 100644 packages/storybook/.babelrc.json delete mode 100644 packages/storybook/.storybook/decorators.tsx create mode 100644 packages/storybook/.storybook/polyfill.ts create mode 100644 packages/storybook/src/decorators/globalDecorators.tsx create mode 100644 packages/storybook/src/decorators/routerDecorators.tsx create mode 100644 packages/storybook/src/features/accounts/AccountListHiddenScreen.stories.tsx create mode 100644 packages/storybook/src/features/accounts/AccountListScreen.stories.tsx create mode 100644 packages/storybook/src/features/accounts/AccountListScreenItem.stories.tsx create mode 100644 packages/storybook/src/features/accounts/AccountNavigationBar.stories.tsx create mode 100644 packages/storybook/src/features/accounts/AccountScreenEmpty.stories.tsx create mode 100644 packages/storybook/src/features/accounts/AddNewAccountScreen.stories.tsx create mode 100644 packages/storybook/src/features/accounts/AddressBookMenu.stories.tsx create mode 100644 packages/storybook/src/features/accounts/DeployAccountScreen.stories.tsx create mode 100644 packages/storybook/src/features/accounts/HideOrDeleteAccountConfirmScreen.stories.tsx create mode 100644 packages/storybook/src/features/accounts/MigrationDisclaimerScreen.stories.tsx create mode 100644 packages/storybook/src/features/accounts/UpgradeScreen.stories.tsx create mode 100644 packages/storybook/src/features/accounts/UpgradeScreenV4.stories.tsx create mode 100644 packages/storybook/src/features/accounts/WarningScreen.stories.tsx create mode 100644 packages/storybook/src/features/actions/AddTokenScreen.stories.tsx create mode 100644 packages/storybook/src/features/actions/ApproveSignatureScreen.stories.tsx delete mode 100644 packages/storybook/src/features/actions/ApproveTransaction.stories.tsx create mode 100644 packages/storybook/src/features/actions/ApproveTransactionScreen.stories.tsx create mode 100644 packages/storybook/src/features/actions/ConnectDappScreen.stories.tsx create mode 100644 packages/storybook/src/features/actions/ErrorScreen.stories.tsx create mode 100644 packages/storybook/src/features/actions/LoadingScreen.stories.tsx delete mode 100644 packages/storybook/src/features/actions/UpgradeScreenV4.stories.tsx create mode 100644 packages/storybook/src/features/actions/__fixtures__/accounts.ts delete mode 100644 packages/storybook/src/features/actions/__fixtures__/aspect.json create mode 100644 packages/storybook/src/features/actions/__fixtures__/aspect.ts delete mode 100644 packages/storybook/src/features/actions/__fixtures__/jediswap.json create mode 100644 packages/storybook/src/features/actions/__fixtures__/jediswap.ts delete mode 100644 packages/storybook/src/features/actions/__fixtures__/transfer.json create mode 100644 packages/storybook/src/features/actions/__fixtures__/transfer.ts delete mode 100644 packages/storybook/src/features/onboarding/OnboardingPrivacyScreen.stories.tsx create mode 100644 packages/storybook/src/features/onboarding/OnboardingPrivacyStatementScreen.stories.tsx delete mode 100644 packages/storybook/src/features/onboarding/OnboardingRestorePassword.stories.tsx delete mode 100644 packages/storybook/src/features/onboarding/OnboardingRestoreSeed.stories.tsx create mode 100644 packages/storybook/src/features/onboarding/OnboardingRestoreSeedScreen.stories.tsx create mode 100644 packages/storybook/src/features/onboarding/OnboardingScreen.stories.tsx create mode 100644 packages/storybook/src/features/onboarding/OnboardingToastMessage.stories.tsx rename packages/storybook/src/{ui/components => theme}/Icons.stories.tsx (59%) rename packages/storybook/src/{ui/components => theme}/Logos.stories.tsx (59%) delete mode 100644 packages/storybook/src/ui/components/AlertDialogDeprecated.stories.tsx delete mode 100644 packages/storybook/src/ui/components/IconsDeprecated.stories.tsx create mode 100644 packages/ui/src/components/ActivityRow.tsx create mode 100644 packages/ui/src/components/PasswordStrengthIndicator.tsx create mode 100644 packages/ui/src/components/PreBox.tsx create mode 100644 packages/ui/src/components/Progress.tsx create mode 100644 packages/ui/src/components/RoundButton.tsx create mode 100644 packages/ui/src/components/SplitProgress.tsx create mode 100644 packages/ui/src/components/StickyGroup.tsx create mode 100644 packages/ui/src/components/TokenIcon.tsx create mode 100644 packages/ui/src/components/fees/CombinedFeeEstimation.tsx create mode 100644 packages/ui/src/components/fees/FeeEstimateError.tsx create mode 100644 packages/ui/src/components/fees/FeeEstimation.tsx create mode 100644 packages/ui/src/components/fees/index.ts create mode 100644 packages/ui/src/components/fees/types.ts create mode 100644 packages/ui/src/components/fees/utils.tsx create mode 100644 packages/ui/src/components/icons/AddCircleIcon.tsx create mode 100644 packages/ui/src/components/icons/AddressIcon.tsx create mode 100644 packages/ui/src/components/icons/AlertFillIcon.tsx create mode 100644 packages/ui/src/components/icons/BinIcon.tsx create mode 100644 packages/ui/src/components/icons/InfoFillIcon.tsx create mode 100644 packages/ui/src/components/icons/QrIcon.tsx create mode 100644 packages/ui/src/components/logos/ArgentLogo.tsx create mode 100644 packages/ui/src/components/logos/MultisigDiagram.tsx create mode 100644 packages/ui/src/components/transactions/AccountNetworkInfo.tsx create mode 100644 packages/ui/src/components/transactions/BalanceChangeOverview.tsx create mode 100644 packages/ui/src/components/transactions/DappHeader.tsx create mode 100644 packages/ui/src/components/transactions/NftDetails.tsx create mode 100644 packages/ui/src/components/transactions/PrettyAccountAddress.tsx create mode 100644 packages/ui/src/components/transactions/TransactionActions.tsx create mode 100644 packages/ui/src/components/transactions/TransactionBanner.tsx create mode 100644 packages/ui/src/components/transactions/TransactionIcon/DeclareTransactionIcon.tsx create mode 100644 packages/ui/src/components/transactions/TransactionIcon/IconWrapper.tsx create mode 100644 packages/ui/src/components/transactions/TransactionIcon/NftTransactionIcon.tsx create mode 100644 packages/ui/src/components/transactions/TransactionIcon/SendTransactionIcon.tsx create mode 100644 packages/ui/src/components/transactions/TransactionIcon/SwapTransactionIcon.tsx create mode 100644 packages/ui/src/components/transactions/TransactionIcon/UnknownDappIcon.tsx create mode 100644 packages/ui/src/components/transactions/TransactionIcon/UnknownTokenIcon.tsx create mode 100644 packages/ui/src/components/transactions/TransactionIcon/VerifiedDappIcon.tsx create mode 100644 packages/ui/src/components/transactions/TransactionIcon/index.tsx create mode 100644 packages/ui/src/components/transactions/TransactionTitle.tsx create mode 100644 packages/ui/src/components/transactions/VerifiedDappBanner.tsx create mode 100644 packages/ui/src/components/transactions/VerifiedDappModal.tsx create mode 100644 packages/ui/src/components/transactions/index.ts create mode 100644 packages/ui/src/hooks/usePasswordStrength.ts create mode 100644 packages/web-sdk/.eslintrc.json create mode 100644 packages/web-sdk/.gitignore create mode 100644 packages/web-sdk/package.json create mode 100644 packages/web-sdk/src/main.ts create mode 100644 packages/web-sdk/src/vite-env.d.ts create mode 100644 packages/web-sdk/src/wormhole.ts create mode 100644 packages/web-sdk/tsconfig.json create mode 100644 packages/web-sdk/vite.config.ts delete mode 100644 packages/window/src/messages/__tests__/bidirectional.test.ts create mode 100644 patches/@noble+hashes+1.3.0.patch rename patches/{starknet+4.21.0.patch => starknet+4.22.0.patch} (95%) mode change 100755 => 100644 scripts/update-starknet-js.sh delete mode 100644 sonar-project.properties diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 0cc5d53b7..d1a9dc15b 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -5,23 +5,31 @@ on: - develop pull_request: +env: + FEATURE_PRIVACY_SETTINGS: "true" + FEATURE_EXPERIMENTAL_SETTINGS: "true" + FEATURE_BANXA: "true" + FEATURE_LAYERSWAP: "true" + FEATURE_ORBITER: "true" + FEATURE_VERIFIED_DAPPS: "true" + FEATURE_ARGENT_SHIELD: "true" + ARGENT_SHIELD_NETWORK_ID: "mainnet-alpha" + FEATURE_MULTISIG: "false" + + SEGMENT_WRITE_KEY: ${{ secrets.SEGMENT_WRITE_KEY }} + SENTRY_DSN: ${{ secrets.SENTRY_DSN }} + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + RAMP_API_KEY: ${{ secrets.RAMP_API_KEY }} + SAFE_ENV_VARS: true + ARGENT_API_BASE_URL: ${{ vars.ARGENT_API_BASE_URL }} + ARGENT_TRANSACTION_REVIEW_API_BASE_URL: ${{ vars.ARGENT_TRANSACTION_REVIEW_API_BASE_URL }} + ARGENT_X_STATUS_URL: ${{ vars.ARGENT_X_STATUS_URL }} + ARGENT_EXPLORER_BASE_URL: ${{ vars.ARGENT_EXPLORER_BASE_URL }} + jobs: setup: runs-on: ubuntu-latest - env: - FEATURE_PRIVACY_SETTINGS: "true" - FEATURE_EXPERIMENTAL_SETTINGS: "true" - FEATURE_BANXA: "true" - FEATURE_LAYERSWAP: "true" - FEATURE_ORBITER: "true" - FEATURE_VERIFIED_DAPPS: "false" - ARGENT_API_BASE_URL: ${{ vars.ARGENT_API_BASE_URL }} - ARGENT_TRANSACTION_REVIEW_API_BASE_URL: ${{ vars.ARGENT_TRANSACTION_REVIEW_API_BASE_URL }} - ARGENT_X_STATUS_URL: ${{ vars.ARGENT_X_STATUS_URL }} - ARGENT_EXPLORER_BASE_URL: ${{ vars.ARGENT_EXPLORER_BASE_URL }} - UPLOAD_SENTRY_SOURCEMAPS: false - steps: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 @@ -35,12 +43,28 @@ jobs: - name: Build extension run: yarn lerna run --scope @argent-x/extension build + - name: Check bundlesize for Chrome + run: yarn run bundlewatch + - name: Cache build uses: actions/cache@v3 with: path: ./* key: ${{ github.sha }} + - name: Set filename prefix + run: echo "FILENAME_PREFIX=$(echo argent-extension-${{ github.ref_name }} | tr / -)" >> $GITHUB_ENV + + - name: Create chrome zip + run: (cd ./packages/extension/dist && zip -r "../../../${{ env.FILENAME_PREFIX }}-chrome.zip" .) + + - name: Upload Chrome extension + uses: actions/upload-artifact@v3 + with: + name: ${{ env.FILENAME_PREFIX }}-chrome.zip + path: "*-chrome.zip" + retention-days: 5 + test-unit: runs-on: ubuntu-latest needs: [setup] @@ -57,6 +81,7 @@ jobs: with: node-version: "16" cache: "yarn" + fetch-depth: 0 - name: Restore cached build uses: actions/cache@v3 @@ -69,11 +94,23 @@ jobs: - name: Run tests run: yarn 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@v1.8 + with: + projectBaseDir: ./packages/extension + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SONAR_TOKEN: ${{ secrets.SONARCLOUD_TOKEN }} test-e2e: runs-on: ubuntu-latest needs: [setup] - + strategy: + matrix: + project: [chromium] + shardIndex: [1, 2, 3, 4, 5, 6, 7, 8] + shardTotal: [8] services: devnet: image: argentlabs-argent-x.jfrog.io/e2e-starknet-devnet:latest @@ -85,7 +122,6 @@ jobs: steps: - uses: actions/checkout@v3 - - uses: actions/setup-node@v3 with: node-version: "16" @@ -101,7 +137,7 @@ jobs: run: npx playwright install chromium - name: Run e2e tests - run: xvfb-run --auto-servernum yarn test:e2e + run: xvfb-run --auto-servernum yarn test:e2e --project=${{ matrix.project }} --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} - name: Upload artifacts uses: actions/upload-artifact@v3 @@ -109,13 +145,14 @@ jobs: with: name: test-results path: | - packages/extension/test-results/ - packages/extension/e2e/artifacts/playwright/ - packages/extension/e2e/artifacts/reports/ + packages/test-results/ + packages/e2e/artifacts/playwright/ + packages/e2e/artifacts/reports/ retention-days: 5 - sonar: + build_firefox_extension: runs-on: ubuntu-latest + if: ${{ github.event_name == 'pull_request' }} # Run only for pull requests needs: [setup] steps: @@ -131,19 +168,35 @@ jobs: path: ./* key: ${{ github.sha }} - - name: SonarCloud Scan - uses: SonarSource/sonarcloud-github-action@master - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - SONAR_TOKEN: ${{ secrets.SONARCLOUD_TOKEN }} + - name: Build Firefox version + run: MANIFEST_VERSION=v2 yarn --cwd packages/extension build + + - name: Set filename prefix + run: echo "FILENAME_PREFIX=$(echo argent-extension-${{ github.ref_name }} | tr / -)" >> $GITHUB_ENV + + - name: Create firefox zip + run: (cd ./packages/extension/dist && zip -r "../../../${{ env.FILENAME_PREFIX }}-firefox.zip" .) + + - name: Check bundlesize for firefox + run: yarn run bundlewatch + + - name: Upload artifacts for firefox + uses: actions/upload-artifact@v3 + with: + name: ${{ env.FILENAME_PREFIX }}-firefox.zip + path: "*-firefox.zip" + retention-days: 5 - artifacts: + create_sentry_release: runs-on: ubuntu-latest - if: ${{ github.event_name == 'pull_request' }} # Run only for pull requests - needs: [setup, test-unit, test-e2e] + if: ${{ github.event_name == 'pull_request' && github.actor != 'dependabot[bot]'}} # Run only for pull requests and if not triggered by dependabot + needs: [setup] steps: - uses: actions/checkout@v3 + with: + fetch-depth: 0 + - uses: actions/setup-node@v3 with: node-version: "16" @@ -155,34 +208,43 @@ jobs: path: ./* key: ${{ github.sha }} - - name: Set filename prefix - run: echo "FILENAME_PREFIX=$(echo argent-extension-${{ github.ref_name }} | tr / -)" >> $GITHUB_ENV + - name: Build extension + run: yarn lerna run --scope @argent-x/extension build - - name: Create chrome zip - run: (cd ./packages/extension/dist && zip -r "../../../${{ env.FILENAME_PREFIX }}-chrome.zip" .) + - 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: Upload artifacts for chrome - uses: actions/upload-artifact@v3 + - name: Check sourcemaps + 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: - name: ${{ env.FILENAME_PREFIX }}-chrome.zip - path: "*-chrome.zip" - retention-days: 5 - - - name: Build Firefox version - run: MANIFEST_VERSION=v2 yarn --cwd packages/extension build + environment: staging + sourcemaps: "./packages/extension/sourcemaps" + version: ${{ steps.package-version.outputs.current-version }}-rc__${{ github.sha }} + ignore_missing: true - - name: Create firefox zip - run: (cd ./packages/extension/dist && zip -r "../../../${{ env.FILENAME_PREFIX }}-firefox.zip" .) - - - name: Check bundlesize for firefox - run: yarn run bundlewatch + 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 + needs: [build_firefox_extension, test-unit, test-e2e] - - name: Upload artifacts for firefox - uses: actions/upload-artifact@v3 - with: - name: ${{ env.FILENAME_PREFIX }}-firefox.zip - path: "*-firefox.zip" - retention-days: 5 + steps: + - uses: actions/checkout@v3 - name: Set GHA_BRANCH run: echo "GHA_BRANCH=$(echo $GITHUB_REF | awk -F / '{print $3}')" >> $GITHUB_ENV diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9609502cc..9d596d790 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,7 +3,7 @@ name: Release on: push: tags: - - "v*.*.*" + - "extension/*" env: FEATURE_PRIVACY_SETTINGS: "true" @@ -14,25 +14,25 @@ env: FEATURE_VERIFIED_DAPPS: "true" FEATURE_ARGENT_SHIELD: "true" ARGENT_SHIELD_NETWORK_ID: "mainnet-alpha" + FEATURE_MULTISIG: "false" + NPM_ACCESS_TOKEN: ${{ secrets.NPM_ACCESS_TOKEN }} + SEGMENT_WRITE_KEY: ${{ secrets.SEGMENT_WRITE_KEY }} + SENTRY_DSN: ${{ secrets.SENTRY_DSN }} + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + RAMP_API_KEY: ${{ secrets.RAMP_API_KEY }} + FILENAME: argent-extension + SAFE_ENV_VARS: true + ARGENT_API_BASE_URL: ${{ vars.ARGENT_API_BASE_URL }} + ARGENT_TRANSACTION_REVIEW_API_BASE_URL: ${{ vars.ARGENT_TRANSACTION_REVIEW_API_BASE_URL }} + ARGENT_X_STATUS_URL: ${{ vars.ARGENT_X_STATUS_URL }} + ARGENT_EXPLORER_BASE_URL: ${{ vars.ARGENT_EXPLORER_BASE_URL }} jobs: build: runs-on: ubuntu-latest permissions: contents: write - env: - NPM_ACCESS_TOKEN: ${{ secrets.NPM_ACCESS_TOKEN }} - SEGMENT_WRITE_KEY: ${{ secrets.SEGMENT_WRITE_KEY }} - SENTRY_DSN: ${{ secrets.SENTRY_DSN }} - SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} - RAMP_API_KEY: ${{ secrets.RAMP_API_KEY }} - FILENAME: argent-extension-${{ github.ref_name }} - UPLOAD_SENTRY_SOURCEMAPS: true - SAFE_ENV_VARS: true - ARGENT_API_BASE_URL: ${{ vars.ARGENT_API_BASE_URL }} - ARGENT_TRANSACTION_REVIEW_API_BASE_URL: ${{ vars.ARGENT_TRANSACTION_REVIEW_API_BASE_URL }} - ARGENT_X_STATUS_URL: ${{ vars.ARGENT_X_STATUS_URL }} - ARGENT_EXPLORER_BASE_URL: ${{ vars.ARGENT_EXPLORER_BASE_URL }} + steps: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 @@ -40,7 +40,7 @@ jobs: node-version: "16" cache: "yarn" - run: yarn setup - - run: yarn build + - run: yarn build --ignore @argent/web - name: Release npm packages # if flow is triggered by a tag, publish to npm @@ -69,7 +69,6 @@ jobs: run: yarn run bundlewatch - name: Upload artifacts for chrome - if: ${{ !startsWith(github.ref, 'refs/tags/') }} uses: actions/upload-artifact@v3 with: name: chrome @@ -78,7 +77,6 @@ jobs: if-no-files-found: error - name: Upload artifacts for firefox - if: ${{ !startsWith(github.ref, 'refs/tags/') }} uses: actions/upload-artifact@v3 with: name: firefox @@ -86,6 +84,38 @@ jobs: retention-days: 14 if-no-files-found: error + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - 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 + 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: production + sourcemaps: "./packages/extension/sourcemaps" + url_prefix: "~/sourcemaps" + version: ${{ steps.package-version.outputs.current-version }} + ignore_missing: true + - name: Release if: startsWith(github.ref, 'refs/tags/') uses: softprops/action-gh-release@v1 diff --git a/.gitignore b/.gitignore index c435fb0a2..a64bb2498 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,5 @@ sourcemaps coverage *.tsbuildinfo license-report.md + +**/.next \ No newline at end of file diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 000000000..bdef82015 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["svelte.svelte-vscode"] +} diff --git a/.vscode/settings.json b/.vscode/settings.json index 75c51808f..88d913bef 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -8,5 +8,9 @@ "**/.{idea,git,cache,output,temp}/**" ], "vitest.enable": true, - "vitest.commandLine": "npx vitest -r packages/extension/" + "vitest.commandLine": "npx vitest -r packages/extension/", + "explorer.fileNesting.patterns": { + "*.tsx": "${capture}.ts, ${capture}.typegen.ts, ${capture}Container.tsx, ${capture}.container.tsx, ${capture}.test.tsx, ${capture}.spec.tsx, ${capture}.test.ts, ${capture}.spec.ts", + "*.ts": "${capture}.ts, ${capture}.typegen.ts, ${capture}Container.tsx, ${capture}.container.tsx, ${capture}.test.tsx, ${capture}.spec.tsx, ${capture}.test.ts, ${capture}.spec.ts" + } } diff --git a/Readme.md b/Readme.md index e8ff8f5cb..9f1524b9c 100644 --- a/Readme.md +++ b/Readme.md @@ -13,6 +13,8 @@

+[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=argentlabs_argent-x-private&metric=alert_status&token=37f8b93db6e967b7992252f0e70c62ff6da11bbb)](https://sonarcloud.io/summary/new_code?id=argentlabs_argent-x-private) [![Technical Debt](https://sonarcloud.io/api/project_badges/measure?project=argentlabs_argent-x-private&metric=sqale_index&token=37f8b93db6e967b7992252f0e70c62ff6da11bbb)](https://sonarcloud.io/summary/new_code?id=argentlabs_argent-x-private) [![Coverage](https://sonarcloud.io/api/project_badges/measure?project=argentlabs_argent-x-private&metric=coverage&token=37f8b93db6e967b7992252f0e70c62ff6da11bbb)](https://sonarcloud.io/summary/new_code?id=argentlabs_argent-x-private) + ---

🌈 Table of contents

diff --git a/lerna.json b/lerna.json index 6b49afb0a..a97799c58 100644 --- a/lerna.json +++ b/lerna.json @@ -1,6 +1,6 @@ { "packages": ["packages/*"], - "version": "5.3.21", + "version": "6.3.0", "npmClient": "yarn", "useWorkspaces": true } diff --git a/package.json b/package.json index d487757c8..2acfe5c07 100644 --- a/package.json +++ b/package.json @@ -18,13 +18,19 @@ "prettier-plugin-import-sort": "^0.0.7", "ts-node": "^10.8.1" }, + "workspaces": { + "nohoist": [ + "packages/web", + "packages/dapp" + ], + "packages": [ + "packages/*" + ] + }, "resolutions": { "@babel/preset-react": "7.17.12", "@babel/plugin-transform-react-jsx": "7.17.12" }, - "workspaces": [ - "packages/*" - ], "scripts": { "format": "prettier --loglevel warn --write \"**/*.{js,jsx,ts,tsx,css,md,yml,json}\"", "dev": "NODE_ENV=development lerna run dev --parallel", @@ -36,13 +42,14 @@ "lint": "lerna run lint --stream", "test": "lerna run test --stream", "test:watch": "lerna run test:watch --stream", - "test:e2e": "lerna run test:e2e --stream", + "test:e2e": "yarn workspace @argent-x/e2e run test:e2e", "setup": "yarn install --frozen-lockfile && yarn allow-scripts && husky install && patch-package && lerna run setup --stream", - "test:ci": "lerna run test:ci --stream", + "test:ci": "lerna run test:ci --stream --parallel", "storybook": "cd packages/storybook && yarn 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", - "version": "lerna version --no-push --no-git-tag-version && yarn --cwd packages/extension run version" + "version": "lerna version --no-push --no-git-tag-version && yarn --cwd packages/extension run version", + "export": "lerna run export --scope=@argent/web" }, "importSort": { ".js, .jsx, .ts, .tsx": { diff --git a/packages/dapp/next.config.js b/packages/dapp/next.config.js index 52e300ffa..4b1bd3055 100644 --- a/packages/dapp/next.config.js +++ b/packages/dapp/next.config.js @@ -1,5 +1,5 @@ /** @type {import('next').NextConfig} */ module.exports = { reactStrictMode: true, - swcMinify: true, + swcMinify: false, // we need to use terser, as swc doesn't support svelte and `@argent/get-starknet` } diff --git a/packages/dapp/package.json b/packages/dapp/package.json index 398d4b498..942cbabba 100644 --- a/packages/dapp/package.json +++ b/packages/dapp/package.json @@ -1,6 +1,6 @@ { "name": "@argent-x/dapp", - "version": "5.3.21", + "version": "6.3.0", "private": true, "scripts": { "dev": "next dev", @@ -10,8 +10,8 @@ "lint": "next lint" }, "dependencies": { - "@argent/get-starknet": "^5.3.21", - "@argent/x-sessions": "^5.3.21", + "@argent/get-starknet": "^6.3.0", + "@argent/x-sessions": "^6.3.0", "ethers": "^5.5.1", "next": "^13.0.0", "react": "^18.0.0", @@ -20,7 +20,7 @@ "starknet5": "npm:starknet@5.0.0-beta.3" }, "devDependencies": { - "@types/node": "18.15.5", + "@types/node": "18.15.12", "@types/react": "^18.0.0", "@types/react-dom": "^18.0.0", "eslint": "8", diff --git a/packages/dapp/src/components/TokenDapp.tsx b/packages/dapp/src/components/TokenDapp.tsx index 16cf07787..4b44daed0 100644 --- a/packages/dapp/src/components/TokenDapp.tsx +++ b/packages/dapp/src/components/TokenDapp.tsx @@ -6,16 +6,16 @@ import { hash } from "starknet5" import Erc20Abi from "../../abi/ERC20.json" import { truncateAddress, truncateHex } from "../services/address.service" import { - getErc20TokenAddress, + DAITokenAddress, + ETHTokenAddress, mintToken, parseInputAmountToUint256, transfer, } from "../services/token.service" import { + addNetwork, addToken, declare, - getExplorerBaseUrl, - networkId, signMessage, waitForTransaction, } from "../services/wallet.service" @@ -55,6 +55,7 @@ export const TokenDapp: FC<{ const [addTokenError, setAddTokenError] = useState("") const [classHash, setClassHash] = useState("") const [contract, setContract] = useState() + const [addNetworkError, setAddNetworkError] = useState("") const [sessionSigner] = useState(genKeyPair()) const [sessionAccount, setSessionAccount] = useState< @@ -82,20 +83,19 @@ export const TokenDapp: FC<{ })() }, [transactionStatus, lastTransactionHash]) - const network = networkId() - if (network !== "goerli-alpha" && network !== "mainnet-alpha") { - return ( - <> -

- There is no demo token for this network, but you can deploy one and - add its address to this file: -

-
-
packages/dapp/src/token.service.ts
-
- - ) - } + // if (network !== "goerli-alpha" && network !== "mainnet-alpha") { + // return ( + // <> + //

+ // There is no demo token for this network, but you can deploy one and + // add its address to this file: + //

+ //
+ //
packages/dapp/src/token.service.ts
+ //
+ // + // ) + // } const handleMintSubmit = async (e: React.FormEvent) => { e.preventDefault() @@ -103,7 +103,7 @@ export const TokenDapp: FC<{ setTransactionStatus("approve") console.log("mint", mintAmount) - const result = await mintToken(mintAmount, network) + const result = await mintToken(mintAmount) console.log(result) setLastTransactionHash(result.transaction_hash) @@ -119,8 +119,7 @@ export const TokenDapp: FC<{ e.preventDefault() setTransactionStatus("approve") - console.log("transfer", { transferTo, transferAmount }) - const result = await transfer(transferTo, transferAmount, network) + const result = await transfer(transferTo, transferAmount) console.log(result) setLastTransactionHash(result.transaction_hash) @@ -156,7 +155,7 @@ export const TokenDapp: FC<{ expires: Math.floor((Date.now() + 1000 * 60 * 60 * 24) / 1000), // 1 day in seconds policies: [ { - contractAddress: getErc20TokenAddress(network), + contractAddress: ETHTokenAddress, selector: "transfer", }, ], @@ -182,8 +181,8 @@ export const TokenDapp: FC<{ } const erc20Contract = new Contract( Erc20Abi as Abi, - getErc20TokenAddress(network), - sessionAccount, + ETHTokenAddress, + sessionAccount as any, ) const result = await erc20Contract.transfer( @@ -220,7 +219,14 @@ export const TokenDapp: FC<{ } } - const tokenAddress = getErc20TokenAddress(network as any) + const handleAddNetwork = async () => { + await addNetwork({ + id: "dapp-test", + chainId: "SN_DAPP_TEST", + chainName: "Test chain name", + baseUrl: "http://localhost:5050", + }) + } return ( <> @@ -231,7 +237,7 @@ export const TokenDapp: FC<{

Transaction hash:{" "} -

- ETH token address - -
- -
+
+

ERC20

+ ETH token address +
+ +
+ {truncateAddress(ETHTokenAddress)} + + +
+

- {addTokenError} + Add ETH token to wallet + +
+ + {addTokenError} + +
+

Network

+ +
+ {addNetworkError} +
+ ) } diff --git a/packages/dapp/src/pages/index.tsx b/packages/dapp/src/pages/index.tsx index 57795c689..5e600d412 100644 --- a/packages/dapp/src/pages/index.tsx +++ b/packages/dapp/src/pages/index.tsx @@ -1,7 +1,8 @@ +import { StarknetWindowObject } from "@argent/get-starknet" import { supportsSessions } from "@argent/x-sessions" import type { NextPage } from "next" import Head from "next/head" -import { useEffect, useState } from "react" +import { useCallback, useEffect, useState } from "react" import { AccountInterface } from "starknet" import { TokenDapp } from "../components/TokenDapp" @@ -18,7 +19,7 @@ import styles from "../styles/Home.module.css" const Home: NextPage = () => { const [address, setAddress] = useState() const [supportSessions, setSupportsSessions] = useState(null) - const [chain, setChain] = useState(chainId()) + const [chain, setChain] = useState(undefined) const [isConnected, setConnected] = useState(false) const [account, setAccount] = useState(null) @@ -26,13 +27,13 @@ const Home: NextPage = () => { const handler = async () => { const wallet = await silentConnectWallet() setAddress(wallet?.selectedAddress) - setChain(chainId()) + setChain(chainId(wallet?.provider as any)) setConnected(!!wallet?.isConnected) if (wallet?.account) { - setAccount(wallet.account) + setAccount(wallet.account as any) } setSupportsSessions(null) - if (wallet?.selectedAddress) { + if (wallet?.selectedAddress && wallet.provider) { try { const sessionSupport = await supportsSessions( wallet.selectedAddress, @@ -55,32 +56,41 @@ const Home: NextPage = () => { } }, []) - const handleConnectClick = async () => { - const wallet = await connectWallet() - setAddress(wallet?.selectedAddress) - setChain(chainId()) - setConnected(!!wallet?.isConnected) - if (wallet?.account) { - setAccount(wallet.account) - } - setSupportsSessions(null) - if (wallet?.selectedAddress) { - const sessionSupport = await supportsSessions( - wallet.selectedAddress, - wallet.provider, - ) - console.log( - "🚀 ~ file: index.tsx ~ line 72 ~ handleConnectClick ~ sessionSupport", - sessionSupport, - ) - setSupportsSessions(sessionSupport) - } - } + const handleConnectClick = useCallback( + ( + connectWallet: ( + enableWebWallet: boolean, + ) => Promise, + enableWebWallet = true, + ) => + async () => { + const wallet = await connectWallet(enableWebWallet) + setAddress(wallet?.selectedAddress) + setChain(chainId(wallet?.provider as any)) + setConnected(!!wallet?.isConnected) + if (wallet?.account) { + setAccount(wallet.account as any) + } + setSupportsSessions(null) + if (wallet?.selectedAddress && wallet.provider) { + try { + const sessionSupport = await supportsSessions( + wallet.selectedAddress, + wallet.provider, + ) + setSupportsSessions(sessionSupport) + } catch { + setSupportsSessions(false) + } + } + }, + [], + ) return (
- Argent x StarkNet test dapp + Test dapp @@ -102,9 +112,18 @@ const Home: NextPage = () => { ) : ( <> - +

First connect wallet to use dapp.

)} diff --git a/packages/dapp/src/services/token.service.ts b/packages/dapp/src/services/token.service.ts index 985fee51f..80e25b6b0 100644 --- a/packages/dapp/src/services/token.service.ts +++ b/packages/dapp/src/services/token.service.ts @@ -1,21 +1,15 @@ -import { getStarknet } from "@argent/get-starknet" +import { connect } from "@argent/get-starknet" import { utils } from "ethers" import { Abi, Contract, number, uint256 } from "starknet" import Erc20Abi from "../../abi/ERC20.json" +import { windowStarknet } from "./wallet.service" -export const erc20TokenAddressByNetwork = { - "goerli-alpha": - "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", - "mainnet-alpha": - "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", -} - -export type PublicNetwork = keyof typeof erc20TokenAddressByNetwork -export type Network = PublicNetwork | "localhost" +export const ETHTokenAddress = + "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7" -export const getErc20TokenAddress = (network: PublicNetwork) => - erc20TokenAddressByNetwork[network] +export const DAITokenAddress = + "0x00da114221cb83fa859dbdb4c44beeaa0bb37c7537ad5ae66fe5e0efd20e6eb3" function getUint256CalldataFromBN(bn: number.BigNumberish) { return { type: "struct" as const, ...uint256.bnToUint256(bn) } @@ -28,21 +22,17 @@ export function parseInputAmountToUint256( return getUint256CalldataFromBN(utils.parseUnits(input, decimals).toString()) } -export const mintToken = async ( - mintAmount: string, - network: PublicNetwork, -): Promise => { - const starknet = getStarknet() - if (!starknet?.isConnected) { +export const mintToken = async (mintAmount: string): Promise => { + if (!windowStarknet?.isConnected) { throw Error("starknet wallet not connected") } const erc20Contract = new Contract( Erc20Abi as Abi, - getErc20TokenAddress(network), - starknet.account as any, + ETHTokenAddress, + windowStarknet.account as any, ) - const address = starknet.selectedAddress + const address = windowStarknet.selectedAddress return erc20Contract.mint(address, parseInputAmountToUint256(mintAmount)) } @@ -50,17 +40,15 @@ export const mintToken = async ( export const transfer = async ( transferTo: string, transferAmount: string, - network: PublicNetwork, ): Promise => { - const starknet = getStarknet() - if (!starknet?.isConnected) { + if (!windowStarknet?.isConnected) { throw Error("starknet wallet not connected") } const erc20Contract = new Contract( Erc20Abi as any, - getErc20TokenAddress(network), - starknet.account as any, + ETHTokenAddress, + windowStarknet.account as any, ) return erc20Contract.transfer( diff --git a/packages/dapp/src/services/wallet.service.ts b/packages/dapp/src/services/wallet.service.ts index dd73f358a..2b2322bda 100644 --- a/packages/dapp/src/services/wallet.service.ts +++ b/packages/dapp/src/services/wallet.service.ts @@ -1,58 +1,36 @@ -import { connect, getStarknet } from "@argent/get-starknet" -import { CompiledContract, constants, shortString } from "starknet" +import { StarknetWindowObject, connect } from "@argent/get-starknet" +import type { AddStarknetChainParameters } from "get-starknet-core" +import { ProviderInterface, shortString } from "starknet" -import { Network } from "./token.service" +export let windowStarknet: StarknetWindowObject | null = null export const silentConnectWallet = async () => { - const windowStarknet = await connect({ showList: false }) - if (!windowStarknet?.isConnected) { - await windowStarknet?.enable({ - showModal: false, - starknetVersion: "v4", - } as any) - } - return windowStarknet + const _windowStarknet = await connect({ modalMode: "neverAsk" }) + windowStarknet = _windowStarknet + return windowStarknet ?? undefined } -export const connectWallet = async () => { - const windowStarknet = await connect({ - include: ["argentX"], +export const connectWallet = async (enableWebWallet: boolean) => { + const _windowStarknet = await connect({ + exclude: enableWebWallet ? [] : ["argentWebWallet"], + modalWalletAppearance: "email_first", }) - await windowStarknet?.enable({ starknetVersion: "v4" } as any) - return windowStarknet + windowStarknet = _windowStarknet + return windowStarknet ?? undefined } export const walletAddress = async (): Promise => { - const starknet = getStarknet() - if (!starknet?.isConnected) { - return - } - return starknet.selectedAddress -} - -export const networkId = (): Network | undefined => { - const starknet = getStarknet() - if (!starknet?.isConnected) { + if (!windowStarknet?.isConnected) { return } - try { - const { chainId } = starknet.provider - if (chainId === constants.StarknetChainId.MAINNET) { - return "mainnet-alpha" - } else if (chainId === constants.StarknetChainId.TESTNET) { - return "goerli-alpha" - } else { - return "localhost" - } - } catch {} + return windowStarknet.selectedAddress } export const addToken = async (address: string): Promise => { - const starknet = getStarknet() - if (!starknet?.isConnected) { + if (!windowStarknet?.isConnected) { throw Error("starknet wallet not connected") } - await starknet.request({ + await windowStarknet.request({ type: "wallet_watchAsset", params: { type: "ERC20", @@ -63,36 +41,25 @@ export const addToken = async (address: string): Promise => { }) } -export const getExplorerBaseUrl = (): string | undefined => { - const network = networkId() - if (network === "mainnet-alpha") { - return "https://voyager.online" - } else if (network === "goerli-alpha") { - return "https://goerli.voyager.online" - } -} - -export const chainId = (): string | undefined => { - const starknet = getStarknet() - if (!starknet?.isConnected) { - return - } +export const chainId = (provider?: ProviderInterface): string | undefined => { try { - return shortString.decodeShortString(starknet.provider.chainId) + if (!provider) { + throw Error("no provider") + } + return shortString.decodeShortString(provider.chainId) } catch {} } export const signMessage = async (message: string) => { - const starknet = getStarknet() - if (!starknet?.isConnected) throw Error("starknet wallet not connected") + if (!windowStarknet?.isConnected) throw Error("starknet wallet not connected") if (!shortString.isShortString(message)) { throw Error("message must be a short string") } - return starknet.account.signMessage({ + return windowStarknet.account.signMessage({ domain: { name: "Example DApp", - chainId: networkId() === "mainnet-alpha" ? "SN_MAIN" : "SN_GOERLI", + chainId: windowStarknet.chainId, version: "0.0.1", }, types: { @@ -111,41 +78,47 @@ export const signMessage = async (message: string) => { } export const waitForTransaction = async (hash: string) => { - const starknet = getStarknet() - if (!starknet?.isConnected) { + if (!windowStarknet?.isConnected) { return } - return starknet.provider.waitForTransaction(hash) + return windowStarknet.provider.waitForTransaction(hash) } export const addWalletChangeListener = async ( handleEvent: (accounts: string[]) => void, ) => { - const starknet = getStarknet() - if (!starknet?.isConnected) { + if (!windowStarknet?.isConnected) { return } - starknet.on("accountsChanged", handleEvent) + windowStarknet.on("accountsChanged", handleEvent) } export const removeWalletChangeListener = async ( handleEvent: (accounts: string[]) => void, ) => { - const starknet = getStarknet() - if (!starknet?.isConnected) { + if (!windowStarknet?.isConnected) { return } - starknet.off("accountsChanged", handleEvent) + windowStarknet.off("accountsChanged", handleEvent) } export const declare = async (contract: string, classHash: string) => { - const starknet = getStarknet() - if (!starknet?.isConnected) { + if (!windowStarknet?.isConnected) { throw Error("starknet wallet not connected") } - return starknet.account.declare({ + return windowStarknet.account.declare({ contract, classHash, }) } + +export const addNetwork = async (params: AddStarknetChainParameters) => { + if (!windowStarknet?.isConnected) { + throw Error("starknet wallet not connected") + } + await windowStarknet.request({ + type: "wallet_addStarknetChain", + params, + }) +} diff --git a/packages/dapp/src/styles/globals.css b/packages/dapp/src/styles/globals.css index 4a7390b38..401999535 100644 --- a/packages/dapp/src/styles/globals.css +++ b/packages/dapp/src/styles/globals.css @@ -9,6 +9,7 @@ } .columns > * { + flex-basis: 0; flex-grow: 1; } @@ -74,14 +75,6 @@ body { padding: 0; } -footer, -header, -main { - margin: 0 auto; - max-width: var(--width-content); - padding: 3rem 1rem; -} - hr { background-color: var(--color-bg-secondary); border: none; @@ -285,7 +278,6 @@ sup { a { color: var(--color-link); display: inline-block; - font-weight: bold; text-decoration: none; } @@ -294,11 +286,6 @@ a:active { text-decoration: underline; } -a:hover { - filter: brightness(var(--hover-brightness)); - text-decoration: underline; -} - a b, a em, a i, @@ -390,18 +377,6 @@ button[disabled]:hover { filter: none; } -form { - border: 1px solid var(--color-bg-secondary); - border-radius: var(--border-radius); - box-shadow: var(--box-shadow) var(--color-shadow); - display: block; - max-width: var(--width-card-wide); - min-width: var(--width-card); - padding: 1.5rem; - margin: 2rem 0; - text-align: var(--justify-normal); -} - form header { margin: 1.5rem 0; padding: 1.5rem 0; diff --git a/packages/extension/e2e/.eslintrc.js b/packages/e2e/.eslintrc.js similarity index 100% rename from packages/extension/e2e/.eslintrc.js rename to packages/e2e/.eslintrc.js diff --git a/packages/extension/e2e/.gitignore b/packages/e2e/.gitignore similarity index 100% rename from packages/extension/e2e/.gitignore rename to packages/e2e/.gitignore diff --git a/packages/extension/e2e/Dockerfile b/packages/e2e/Dockerfile similarity index 100% rename from packages/extension/e2e/Dockerfile rename to packages/e2e/Dockerfile diff --git a/packages/extension/e2e/network-setup/Dockerfile b/packages/e2e/extension/network-setup/Dockerfile similarity index 100% rename from packages/extension/e2e/network-setup/Dockerfile rename to packages/e2e/extension/network-setup/Dockerfile diff --git a/packages/extension/e2e/network-setup/build_and_push.sh b/packages/e2e/extension/network-setup/build_and_push.sh similarity index 100% rename from packages/extension/e2e/network-setup/build_and_push.sh rename to packages/e2e/extension/network-setup/build_and_push.sh diff --git a/packages/extension/e2e/network-setup/dump.pkl b/packages/e2e/extension/network-setup/dump.pkl similarity index 100% rename from packages/extension/e2e/network-setup/dump.pkl rename to packages/e2e/extension/network-setup/dump.pkl diff --git a/packages/extension/e2e/src/config.ts b/packages/e2e/extension/src/config.ts similarity index 85% rename from packages/extension/e2e/src/config.ts rename to packages/e2e/extension/src/config.ts index af8ab95e1..313bb9730 100644 --- a/packages/extension/e2e/src/config.ts +++ b/packages/e2e/extension/src/config.ts @@ -2,9 +2,9 @@ import path from "path" export default { password: "MyP@ss3!", - artifactsDir: path.resolve(__dirname, "../artifacts/playwright"), - reportsDir: path.resolve(__dirname, "../artifacts/reports"), - distDir: path.join(__dirname, "../../dist/"), + artifactsDir: path.resolve(__dirname, "../../artifacts/playwright"), + reportsDir: path.resolve(__dirname, "../../artifacts/reports"), + distDir: path.join(__dirname, "../../../extension/dist/"), wallets: [ { diff --git a/packages/extension/e2e/src/fixtures.ts b/packages/e2e/extension/src/fixtures.ts similarity index 100% rename from packages/extension/e2e/src/fixtures.ts rename to packages/e2e/extension/src/fixtures.ts diff --git a/packages/extension/e2e/src/languages/ILanguage.ts b/packages/e2e/extension/src/languages/ILanguage.ts similarity index 98% rename from packages/extension/e2e/src/languages/ILanguage.ts rename to packages/e2e/extension/src/languages/ILanguage.ts index 1d097ec3f..780d66051 100644 --- a/packages/extension/e2e/src/languages/ILanguage.ts +++ b/packages/e2e/extension/src/languages/ILanguage.ts @@ -24,7 +24,6 @@ export interface ILanguage { addFunds: string fundsFromStarkNet: string fullAccountAddress: string - showAccountList: string send: string export: string accountRecovery: string diff --git a/packages/extension/e2e/src/languages/en/index.ts b/packages/e2e/extension/src/languages/en/index.ts similarity index 98% rename from packages/extension/e2e/src/languages/en/index.ts rename to packages/e2e/extension/src/languages/en/index.ts index 730b02ba4..8afb708eb 100644 --- a/packages/extension/e2e/src/languages/en/index.ts +++ b/packages/e2e/extension/src/languages/en/index.ts @@ -25,7 +25,6 @@ const texts = { addFunds: "Add funds", fundsFromStarkNet: "From another StarkNet account", fullAccountAddress: "Full account address", - showAccountList: "Show account list", send: "Send", export: "Export", accountRecovery: "Set up account recovery", diff --git a/packages/extension/e2e/src/languages/index.ts b/packages/e2e/extension/src/languages/index.ts similarity index 99% rename from packages/extension/e2e/src/languages/index.ts rename to packages/e2e/extension/src/languages/index.ts index 653df9343..583a76ea9 100644 --- a/packages/extension/e2e/src/languages/index.ts +++ b/packages/e2e/extension/src/languages/index.ts @@ -1,6 +1,7 @@ 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, diff --git a/packages/extension/e2e/src/page-objects/Account.ts b/packages/e2e/extension/src/page-objects/Account.ts similarity index 91% rename from packages/extension/e2e/src/page-objects/Account.ts rename to packages/e2e/extension/src/page-objects/Account.ts index a891128a0..54d7062d1 100644 --- a/packages/extension/e2e/src/page-objects/Account.ts +++ b/packages/e2e/extension/src/page-objects/Account.ts @@ -44,8 +44,8 @@ export default class Account extends Navigation { return this.page.locator(`button :text-is('${tkn}')`) } - get accountList() { - return this.page.locator(`[aria-label="${lang.account.showAccountList}"]`) + get accountListSelector() { + return this.page.locator(`[aria-label="Show account list"]`) } get addANewccountFromAccountList() { @@ -73,7 +73,7 @@ export default class Account extends Navigation { } account(accountName: string) { - return this.page.locator(`[aria-label="Select ${accountName}"]`) + return this.page.locator(`[aria-label^="Select ${accountName}"]`) } get balance() { @@ -92,28 +92,30 @@ export default class Account extends Navigation { if (firstAccount) { await this.createAccount.click() } else { - await this.accountList.click() + await this.accountListSelector.click() await this.addANewccountFromAccountList.click() } await this.addStandardAccountFromNewAccountScreen.click() - await expect(this.accountList).toBeVisible() + + await this.account("").last().click() + await expect(this.accountListSelector).toBeVisible() await this.addFunds.click() await this.addFundsFromStartNet.click() const accountAddress = await this.accountAddress .textContent() .then((v) => v?.replaceAll(" ", "")) await this.close.last().click() - const accountName = await this.accountList.textContent() + const accountName = await this.accountListSelector.textContent() return [accountName, accountAddress] } async selectAccount(accountName: string) { - await this.accountList.click() + await this.accountListSelector.click() await this.account(accountName).click() } async ensureSelectedAccount(accountName: string) { - const currentAccount = await this.accountList.textContent() + const currentAccount = await this.accountListSelector.textContent() if (currentAccount != accountName) { await this.selectAccount(accountName) } diff --git a/packages/extension/e2e/src/page-objects/Activity.ts b/packages/e2e/extension/src/page-objects/Activity.ts similarity index 100% rename from packages/extension/e2e/src/page-objects/Activity.ts rename to packages/e2e/extension/src/page-objects/Activity.ts diff --git a/packages/extension/e2e/src/page-objects/AddressBook.ts b/packages/e2e/extension/src/page-objects/AddressBook.ts similarity index 100% rename from packages/extension/e2e/src/page-objects/AddressBook.ts rename to packages/e2e/extension/src/page-objects/AddressBook.ts diff --git a/packages/extension/e2e/src/page-objects/DeveloperSettings.ts b/packages/e2e/extension/src/page-objects/DeveloperSettings.ts similarity index 100% rename from packages/extension/e2e/src/page-objects/DeveloperSettings.ts rename to packages/e2e/extension/src/page-objects/DeveloperSettings.ts diff --git a/packages/extension/e2e/src/page-objects/ExtensionPage.ts b/packages/e2e/extension/src/page-objects/ExtensionPage.ts similarity index 100% rename from packages/extension/e2e/src/page-objects/ExtensionPage.ts rename to packages/e2e/extension/src/page-objects/ExtensionPage.ts diff --git a/packages/extension/e2e/src/page-objects/Navigation.ts b/packages/e2e/extension/src/page-objects/Navigation.ts similarity index 100% rename from packages/extension/e2e/src/page-objects/Navigation.ts rename to packages/e2e/extension/src/page-objects/Navigation.ts diff --git a/packages/extension/e2e/src/page-objects/Network.ts b/packages/e2e/extension/src/page-objects/Network.ts similarity index 100% rename from packages/extension/e2e/src/page-objects/Network.ts rename to packages/e2e/extension/src/page-objects/Network.ts diff --git a/packages/extension/e2e/src/page-objects/Settings.ts b/packages/e2e/extension/src/page-objects/Settings.ts similarity index 97% rename from packages/extension/e2e/src/page-objects/Settings.ts rename to packages/e2e/extension/src/page-objects/Settings.ts index 52908afbd..da816b751 100644 --- a/packages/extension/e2e/src/page-objects/Settings.ts +++ b/packages/e2e/extension/src/page-objects/Settings.ts @@ -100,7 +100,7 @@ export default class Settings { } get privacyStatement() { - return this.page.getByRole("link", { name: "Privacy Statement" }) + return this.page.getByRole("link", { name: "Privacy statement" }) } get privacyStatementText() { diff --git a/packages/extension/e2e/src/page-objects/Wallet.ts b/packages/e2e/extension/src/page-objects/Wallet.ts similarity index 85% rename from packages/extension/e2e/src/page-objects/Wallet.ts rename to packages/e2e/extension/src/page-objects/Wallet.ts index 7cb9ae9dc..22cc24c54 100644 --- a/packages/extension/e2e/src/page-objects/Wallet.ts +++ b/packages/e2e/extension/src/page-objects/Wallet.ts @@ -1,6 +1,6 @@ import { Page, expect } from "@playwright/test" -import config from "./../config" +import config from "../config" import { lang } from "../languages" import Navigation from "./Navigation" @@ -9,7 +9,7 @@ export default class Wallet extends Navigation { super(page) } get banner() { - return this.page.locator(`div h1:text-is("${lang.wallet.banner1}")`) + return this.page.locator(`div h2:text-is("${lang.wallet.banner1}")`) } get description() { return this.page.locator(`div p:text-is("${lang.wallet.desc1}")`) @@ -23,7 +23,7 @@ export default class Wallet extends Navigation { //second screen get banner2() { - return this.page.locator(`div h1:text-is("${lang.wallet.banner2}")`) + return this.page.locator(`div h2:text-is("${lang.wallet.banner2}")`) } get description2() { return this.page.locator(`div p:text-is("${lang.wallet.desc2}")`) @@ -31,17 +31,17 @@ export default class Wallet extends Navigation { get disclaimerLostOfFunds() { return this.page.locator( - `//input[@name="lossOfFunds"]/following::p[contains(text(),'${lang.wallet.lossOfFunds}')]`, + `//input[@value="lossOfFunds"]/following::p[contains(text(),'${lang.wallet.lossOfFunds}')]`, ) } get disclaimerAlphaVersion() { return this.page.locator( - `//input[@name="alphaVersion"]/following::p[contains(text(),'${lang.wallet.alphaVersion}')]`, + `//input[@value="alphaVersion"]/following::p[contains(text(),'${lang.wallet.alphaVersion}')]`, ) } get privacyStatement() { - return this.page.getByRole("link", { name: "Privacy Statement" }) + return this.page.getByRole("link", { name: "Privacy statement" }) } get privacyStatementText() { @@ -50,7 +50,7 @@ export default class Wallet extends Navigation { //third screen get banner3() { - return this.page.locator(`div h1:text-is("${lang.wallet.banner3}")`) + return this.page.locator(`div h2:text-is("${lang.wallet.banner3}")`) } get description3() { return this.page.locator(`div p:text-is("${lang.wallet.desc3}")`) @@ -71,7 +71,7 @@ export default class Wallet extends Navigation { //fourth screen get banner4() { - return this.page.locator(`div h1:text-is("${lang.wallet.banner4}")`) + return this.page.locator(`div h2:text-is("${lang.wallet.banner4}")`) } get description4() { return this.page.locator(`div p:text-is("${lang.wallet.desc4}")`) diff --git a/packages/extension/e2e/src/specs/accountSettings.spec.ts b/packages/e2e/extension/src/specs/accountSettings.spec.ts similarity index 100% rename from packages/extension/e2e/src/specs/accountSettings.spec.ts rename to packages/e2e/extension/src/specs/accountSettings.spec.ts diff --git a/packages/extension/e2e/src/specs/addressBook.spec.ts b/packages/e2e/extension/src/specs/addressBook.spec.ts similarity index 100% rename from packages/extension/e2e/src/specs/addressBook.spec.ts rename to packages/e2e/extension/src/specs/addressBook.spec.ts diff --git a/packages/extension/e2e/src/specs/dappsBanner.spec.ts b/packages/e2e/extension/src/specs/dappsBanner.spec.ts similarity index 100% rename from packages/extension/e2e/src/specs/dappsBanner.spec.ts rename to packages/e2e/extension/src/specs/dappsBanner.spec.ts diff --git a/packages/extension/e2e/src/specs/links.spec.ts b/packages/e2e/extension/src/specs/links.spec.ts similarity index 100% rename from packages/extension/e2e/src/specs/links.spec.ts rename to packages/e2e/extension/src/specs/links.spec.ts diff --git a/packages/extension/e2e/src/specs/network.spec.ts b/packages/e2e/extension/src/specs/network.spec.ts similarity index 98% rename from packages/extension/e2e/src/specs/network.spec.ts rename to packages/e2e/extension/src/specs/network.spec.ts index d1dd08526..73ce362cd 100644 --- a/packages/extension/e2e/src/specs/network.spec.ts +++ b/packages/e2e/extension/src/specs/network.spec.ts @@ -67,7 +67,7 @@ test.describe("Network", () => { ).not.toBeVisible() }) - test("User should be able to restore default networks is network is not selected", async ({ + test("User should be able to restore default networks if network is not selected", async ({ extension, }) => { await extension.wallet.newWalletOnboarding() diff --git a/packages/extension/e2e/src/specs/receiveFunds.spec.ts b/packages/e2e/extension/src/specs/receiveFunds.spec.ts similarity index 100% rename from packages/extension/e2e/src/specs/receiveFunds.spec.ts rename to packages/e2e/extension/src/specs/receiveFunds.spec.ts diff --git a/packages/extension/e2e/src/specs/recovery.spec.ts b/packages/e2e/extension/src/specs/recovery.spec.ts similarity index 100% rename from packages/extension/e2e/src/specs/recovery.spec.ts rename to packages/e2e/extension/src/specs/recovery.spec.ts diff --git a/packages/e2e/extension/src/specs/sendFundsMax.spec.ts b/packages/e2e/extension/src/specs/sendFundsMax.spec.ts new file mode 100644 index 000000000..7e4eca6d9 --- /dev/null +++ b/packages/e2e/extension/src/specs/sendFundsMax.spec.ts @@ -0,0 +1,78 @@ +import { expect } from "@playwright/test" + +import test from "../test" + +test.describe("Send MAX funds", () => { + const otherAccount = + "0x02c786C7b4708b476a3a7c012922e6C3a161096F71EC694D61b590dbD4051Faf" + const setupWallet = async (extension: any) => { + await extension.wallet.newWalletOnboarding() + await extension.open() + await expect(extension.network.networkSelector).toBeVisible() + await extension.network.selectNetwork("Localhost 5050") + const [accountName1, accountAddress1] = await extension.account.addAccount( + {}, + ) + const [accountName2, accountAddress2] = await extension.account.addAccount({ + firstAccount: false, + }) + if (!accountName1 || !accountName2 || !accountAddress2) { + throw new Error("Invalid account names") + } + await extension.account.ensureAsset(accountName1, "ETH", "1.0") + await extension.account.ensureAsset(accountName2, "ETH", "1.0") + + return { accountName1, accountAddress1, accountName2, accountAddress2 } + } + + test("send MAX funds to other self account", async ({ extension }) => { + const { accountName1, accountName2, accountAddress2 } = await setupWallet( + extension, + ) + await extension.account.transfer({ + originAccountName: accountName1, + recepientAddress: accountAddress2, + tokenName: "Ethereum", + amount: "MAX", + }) + await extension.activity.checkActivity(1) + await extension.navigation.menuTokens.click() + await expect( + extension.navigation.menuPendingTransationsIndicator, + ).not.toBeVisible() + await expect(extension.account.currentBalance("ETH")).toContainText( + "0.0023", + ) + + await extension.account.token("Ethereum").click() + await expect(extension.account.balance).toContainText("0.0023") + await extension.account.back.click() + await extension.account.ensureSelectedAccount(accountName2) + await extension.account.token("Ethereum").click() + await expect(extension.account.balance).toContainText("1.9965") + await extension.account.back.click() + await expect(extension.account.currentBalance("ETH")).toContainText("1.9") + }) + + test("send MAX funds to other wallet/account", async ({ extension }) => { + const { accountName1 } = await setupWallet(extension) + + await extension.account.transfer({ + originAccountName: accountName1, + recepientAddress: otherAccount, + tokenName: "Ethereum", + amount: "MAX", + }) + await extension.activity.checkActivity(1) + await extension.navigation.menuTokens.click() + await expect( + extension.navigation.menuPendingTransationsIndicator, + ).not.toBeVisible() + await expect(extension.account.currentBalance("ETH")).toContainText( + "0.0023", + ) + + await extension.account.token("Ethereum").click() + await expect(extension.account.balance).toContainText("0.0023") + }) +}) diff --git a/packages/extension/e2e/src/specs/sendFunds.spec.ts b/packages/e2e/extension/src/specs/sendFundsPartial.spec.ts similarity index 60% rename from packages/extension/e2e/src/specs/sendFunds.spec.ts rename to packages/e2e/extension/src/specs/sendFundsPartial.spec.ts index edf344bec..855986b07 100644 --- a/packages/extension/e2e/src/specs/sendFunds.spec.ts +++ b/packages/e2e/extension/src/specs/sendFundsPartial.spec.ts @@ -2,7 +2,7 @@ import { expect } from "@playwright/test" import test from "../test" -test.describe("Send funds", () => { +test.describe("Send partial funds", () => { const otherAccount = "0x02c786C7b4708b476a3a7c012922e6C3a161096F71EC694D61b590dbD4051Faf" const setupWallet = async (extension: any) => { @@ -54,35 +54,6 @@ test.describe("Send funds", () => { await expect(extension.account.currentBalance("ETH")).toContainText("1.5") }) - test("send MAX funds to other self account", async ({ extension }) => { - const { accountName1, accountName2, accountAddress2 } = await setupWallet( - extension, - ) - await extension.account.transfer({ - originAccountName: accountName1, - recepientAddress: accountAddress2, - tokenName: "Ethereum", - amount: "MAX", - }) - await extension.activity.checkActivity(1) - await extension.navigation.menuTokens.click() - await expect( - extension.navigation.menuPendingTransationsIndicator, - ).not.toBeVisible() - await expect(extension.account.currentBalance("ETH")).toContainText( - "0.0023", - ) - - await extension.account.token("Ethereum").click() - await expect(extension.account.balance).toContainText("0.0023") - await extension.account.back.click() - await extension.account.ensureSelectedAccount(accountName2) - await extension.account.token("Ethereum").click() - await expect(extension.account.balance).toContainText("1.9965") - await extension.account.back.click() - await expect(extension.account.currentBalance("ETH")).toContainText("1.9") - }) - test("send partial funds to other wallet/account", async ({ extension }) => { const { accountName1 } = await setupWallet(extension) await extension.account.ensureAsset(accountName1, "ETH", "1.0") @@ -103,26 +74,4 @@ test.describe("Send funds", () => { await extension.account.token("Ethereum").click() await expect(extension.account.balance).toContainText("0.4988") }) - - test("send MAX funds to other wallet/account", async ({ extension }) => { - const { accountName1 } = await setupWallet(extension) - - await extension.account.transfer({ - originAccountName: accountName1, - recepientAddress: otherAccount, - tokenName: "Ethereum", - amount: "MAX", - }) - await extension.activity.checkActivity(1) - await extension.navigation.menuTokens.click() - await expect( - extension.navigation.menuPendingTransationsIndicator, - ).not.toBeVisible() - await expect(extension.account.currentBalance("ETH")).toContainText( - "0.0023", - ) - - await extension.account.token("Ethereum").click() - await expect(extension.account.balance).toContainText("0.0023") - }) }) diff --git a/packages/extension/e2e/src/specs/welcome.spec.ts b/packages/e2e/extension/src/specs/welcome.spec.ts similarity index 100% rename from packages/extension/e2e/src/specs/welcome.spec.ts rename to packages/e2e/extension/src/specs/welcome.spec.ts diff --git a/packages/extension/e2e/src/test.ts b/packages/e2e/extension/src/test.ts similarity index 98% rename from packages/extension/e2e/src/test.ts rename to packages/e2e/extension/src/test.ts index f275d7808..979454025 100644 --- a/packages/extension/e2e/src/test.ts +++ b/packages/e2e/extension/src/test.ts @@ -38,8 +38,8 @@ const keepArtifacts = async (testInfo: TestInfo, page: Page) => { const htmlContent = await page.content() await fs.promises .mkdir(path.resolve(config.artifactsDir, folder), { recursive: true }) - .catch(() => { - null + .catch((error) => { + console.error(error) }) await fs.promises .writeFile( diff --git a/packages/extension/e2e/src/utils/Messages.ts b/packages/e2e/extension/src/utils/Messages.ts similarity index 100% rename from packages/extension/e2e/src/utils/Messages.ts rename to packages/e2e/extension/src/utils/Messages.ts diff --git a/packages/e2e/package.json b/packages/e2e/package.json new file mode 100644 index 000000000..c32477a8f --- /dev/null +++ b/packages/e2e/package.json @@ -0,0 +1,15 @@ +{ + "name": "@argent-x/e2e", + "private": true, + "version": "6.3.0", + "main": "index.js", + "license": "MIT", + "devDependencies": { + "@playwright/test": "^1.32.0", + "@types/node": "^18.15.11" + }, + "scripts": { + "test:e2e:extension": "playwright test ./extension", + "test:e2e": "yarn run test:e2e:extension" + } +} diff --git a/packages/extension/playwright.config.ts b/packages/e2e/playwright.config.ts similarity index 87% rename from packages/extension/playwright.config.ts rename to packages/e2e/playwright.config.ts index 4420f94d6..220201962 100644 --- a/packages/extension/playwright.config.ts +++ b/packages/e2e/playwright.config.ts @@ -2,12 +2,17 @@ import path from "path" import type { PlaywrightTestConfig } from "@playwright/test" -import config from "./e2e/src/config" +import config from "./extension/src/config" const isCI = Boolean(process.env.CI) const playwrightConfig: PlaywrightTestConfig = { - workers: 2, + projects: [ + { + name: "chromium", + }, + ], + workers: 1, timeout: 5 * 60e3, // 5 minutes reportSlowTests: { threshold: 1 * 60e3, // 1 minute @@ -32,7 +37,7 @@ const playwrightConfig: PlaywrightTestConfig = { ] : "list", forbidOnly: isCI, - testDir: "e2e/src/specs", + testDir: "./extension/src/specs", testMatch: /\.spec.ts$/, retries: isCI ? 2 : 0, use: { diff --git a/packages/extension/e2e/tsconfig.json b/packages/e2e/tsconfig.json similarity index 80% rename from packages/extension/e2e/tsconfig.json rename to packages/e2e/tsconfig.json index e0c98e676..826af4096 100644 --- a/packages/extension/e2e/tsconfig.json +++ b/packages/e2e/tsconfig.json @@ -2,21 +2,19 @@ "compilerOptions": { "target": "Esnext", "moduleResolution": "node", + "module": "commonjs", "allowSyntheticDefaultImports": true, "strict": true, "noImplicitAny": true, "noUnusedLocals": true, "noUnusedParameters": true, "noImplicitReturns": true, - "importsNotUsedAsValues": "error", "resolveJsonModule": true, "inlineSources": true, "inlineSourceMap": true, - "noEmit": true, - "skipLibCheck": true, "composite": true, "types": ["node"] }, - "include": ["src"], + "include": ["**/src"], "exclude": ["node_modules"] } diff --git a/packages/extension/.env.example b/packages/extension/.env.example index 2f6eff566..04c5ff7d3 100644 --- a/packages/extension/.env.example +++ b/packages/extension/.env.example @@ -1,6 +1,5 @@ SEGMENT_WRITE_KEY= SENTRY_AUTH_TOKEN= -UPLOAD_SENTRY_SOURCEMAPS= RAMP_API_KEY= ARGENT_API_BASE_URL= ARGENT_TRANSACTION_REVIEW_API_BASE_URL= @@ -17,5 +16,6 @@ ARGENT_X_STATUS_URL= #FEATURE_EXPERIMENTAL_SETTINGS= #FEATURE_ARGENT_SHIELD= #ARGENT_SHIELD_NETWORK_ID= +#FEATURE_VERIFIED_DAPPS= #FEATURE_MULTISIG= -#FEATURE_VERIFIED_DAPPS= \ No newline at end of file +#ARGENT_MULTISIG_BASE_URL= diff --git a/packages/extension/.eslintrc.js b/packages/extension/.eslintrc.js index acefa5cf1..ff07fe963 100644 --- a/packages/extension/.eslintrc.js +++ b/packages/extension/.eslintrc.js @@ -21,7 +21,15 @@ module.exports = { }, ecmaVersion: "latest", sourceType: "module", + project: "./tsconfig.json", + tsconfigRootDir: __dirname, }, + ignorePatterns: [ + "**/dist/**", + "**/node_modules/**", + "vite.config.ts", + "webpack.config.js", + ], plugins: ["react", "react-hooks", "@typescript-eslint"], rules: { "react/jsx-no-target-blank": "off", @@ -52,5 +60,7 @@ module.exports = { ], }, ], + "@typescript-eslint/no-misused-promises": "warn", + "@typescript-eslint/no-floating-promises": "warn", }, } diff --git a/packages/extension/manifest/v2.json b/packages/extension/manifest/v2.json index 6ff353379..78d56a968 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", "description": "The security of Ethereum with the scale of StarkNet", - "version": "5.3.21", + "version": "5.4.0", "manifest_version": 2, "browser_action": { "default_icon": { diff --git a/packages/extension/manifest/v3.json b/packages/extension/manifest/v3.json index 7324f4340..8091866fb 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", "description": "The security of Ethereum with the scale of StarkNet", - "version": "5.3.21", + "version": "5.4.0", "manifest_version": 3, "action": { "default_icon": { diff --git a/packages/extension/package.json b/packages/extension/package.json index 13ebec3bb..62b81e668 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -1,10 +1,10 @@ { "name": "@argent-x/extension", - "version": "5.3.21", + "version": "6.3.0", "main": "index.js", + "private": true, "license": "MIT", "devDependencies": { - "@playwright/test": "^1.31.2", "@sentry/webpack-plugin": "^1.18.9", "@svgr/webpack": "^6.0.0", "@testing-library/jest-dom": "^5.16.5", @@ -24,12 +24,13 @@ "@typescript-eslint/eslint-plugin": "^5.10.1", "@typescript-eslint/parser": "^5.10.1", "@vitejs/plugin-react": "^3.0.0", + "@vitest/coverage-istanbul": "^0.31.0", "chokidar": "^3.5.2", "concurrently": "^7.2.2", "copy-webpack-plugin": "^11.0.0", "cross-fetch": "^3.1.5", "dotenv-webpack": "^8.0.0", - "esbuild-loader": "^2.19.0", + "esbuild-loader": "^3.0.1", "eslint": "^8.7.0", "eslint-plugin-react": "^7.28.0", "eslint-plugin-react-hooks": "^4.6.0", @@ -39,10 +40,11 @@ "fork-ts-checker-webpack-plugin": "^8.0.0", "html-webpack-plugin": "^5.5.0", "isomorphic-fetch": "^3.0.0", - "jsdom": "^21.0.0", + "jsdom": "^22.0.0", "mitt": "^3.0.0", "msw": "^1.0.0", "raw-loader": "^4.0.2", + "type-fest": "^3.9.0", "typescript": "^4.9.4", "typescript-styled-plugin": "^0.18.2", "url-loader": "^4.1.1", @@ -64,8 +66,7 @@ "test": "vitest run", "test:watch": "vitest", "test:ci": "vitest run --coverage", - "test:e2e": "playwright test", - "version": "yarn run change-to-release-branch && yarn run sync-manifest-version && yarn run commit-and-tag-version-changes && yarn run push-release-branch", + "version": "yarn run change-to-release-branch && yarn run commit-and-tag-version-changes && yarn run push-release-branch", "change-to-release-branch": "git checkout -b release/v$npm_package_version", "sync-manifest-version": "concurrently \"yarn sync-manifest-version:v2\" \"yarn sync-manifest-version:v3\"", "sync-manifest-version:v2": "node -p \"JSON.stringify({...require('./manifest/v2.json'), version: '$npm_package_version'}, null, 2)\" > ./manifest/v2.temp.json && prettier --write ./manifest/v2.temp.json && mv ./manifest/v2.temp.json ./manifest/v2.json", @@ -74,24 +75,29 @@ "push-release-branch": "git push --set-upstream origin release/v$npm_package_version --follow-tags" }, "dependencies": { - "@argent/guardian": "^5.3.21", - "@argent/stack-router": "^5.3.21", - "@argent/ui": "^5.3.21", - "@argent/x-multicall": "^5.3.21", - "@argent/x-sessions": "^5.3.21", - "@argent/x-swap": "^5.3.21", - "@argent/x-window": "^5.3.21", + "@argent/guardian": "^6.3.0", + "@argent/stack-router": "^6.3.0", + "@argent/ui": "^6.3.0", + "@argent/x-multicall": "^6.3.0", + "@argent/x-sessions": "^6.3.0", + "@argent/x-swap": "^6.3.0", + "@argent/x-window": "^6.3.0", "@chakra-ui/icons": "^2.0.15", - "@chakra-ui/react": "2.5.1", + "@chakra-ui/react": "^2.6.1", "@extend-chrome/messages": "^1.2.2", "@google/model-viewer": "^3.0.0", + "@hookform/resolvers": "^3.0.1", "@mui/icons-material": "^5.3.1", "@mui/material": "^5.1.0", "@mui/styled-engine-sc": "^5.10.3", "@noble/hashes": "^1.1.3", + "@scure/bip39": "^1.2.0", "@sentry/react": "^7.6.0", "@sentry/tracing": "^7.6.0", "@tippyjs/react": "^4.2.6", + "@trpc/client": "^10.20.0", + "@trpc/server": "^10.20.0", + "@vitest/coverage-istanbul": "^0.31.0", "async-retry": "^1.3.3", "bignumber.js": "^9.0.2", "buffer": "^6.0.3", @@ -100,8 +106,9 @@ "dexie-react-hooks": "^1.1.1", "ethers": "^5.5.1", "jose": "^4.3.6", + "jotai": "^2.0.4", "lodash-es": "^4.17.21", - "micro-starknet": "^0.1.1", + "micro-starknet": "^0.2.3", "nanoid": "^4.0.0", "object-hash": "^3.0.0", "qr-code-styling": "^1.6.0-rc.1", @@ -122,10 +129,11 @@ "styled-components": "^5.3.5", "styled-normalize": "^8.0.7", "swr": "^1.3.0", + "trpc-extension": "^1.1.0", "url-join": "^5.0.0", "webextension-polyfill": "^0.10.0", "yup": "^1.0.0-beta.4", "zod": "^3.20.2", - "zustand": "^3.6.5" + "zustand": "^4.3.6" } } diff --git a/packages/extension/sonar-project.properties b/packages/extension/sonar-project.properties new file mode 100644 index 000000000..9889ec878 --- /dev/null +++ b/packages/extension/sonar-project.properties @@ -0,0 +1,6 @@ +# must be unique in a given SonarQube instance +sonar.projectKey=argentlabs_argent-x-private +sonar.organization=argentlabs +sonar.javascript.lcov.reportPaths=./coverage/lcov.info +sonar.exclusions=**/dist/**,**/spec/**,**/test/**,**/tests/**,**/node_modules/**,**/coverage/**,**/build/**,**/contracts/**,**/migrations/**,**/scripts/**,**/artifacts/**,**/deployments/**,**/deploy/**,**/deployed/**,**/de,**/*.test.tsx,**/*.spec.tsx,**/*.d.ts,**/*.json,**/*.sol,**/*.yml,**/*.yaml,**/*.md,**/*.html,**/*.css,**/*.scss,**/*.sass,**/*.less,**/*.styl, +sonar.sources=. diff --git a/packages/extension/src/assets/default-tokens.json b/packages/extension/src/assets/default-tokens.json index 9eaa49ee5..8c50ef6c2 100644 --- a/packages/extension/src/assets/default-tokens.json +++ b/packages/extension/src/assets/default-tokens.json @@ -128,5 +128,13 @@ "symbol": "SLF", "decimals": "6", "network": "goerli-alpha" + }, + { + "address": "0x0148f970a06fc95ee0682140e23a980351be8fad26168c5f0465e63940c46514", + "name": "Angle agEUR", + "symbol": "agEUR", + "decimals": "18", + "network": "mainnet-alpha", + "image": "https://dv3jj1unlp2jl.cloudfront.net/128/color/ageur.png" } ] diff --git a/packages/extension/src/background/__new/middleware/analytics.ts b/packages/extension/src/background/__new/middleware/analytics.ts new file mode 100644 index 000000000..0acc9e7ef --- /dev/null +++ b/packages/extension/src/background/__new/middleware/analytics.ts @@ -0,0 +1,65 @@ +import { + AnyRootConfig, + MiddlewareFunction, + ProcedureParams, + TRPCError, +} from "@trpc/server" + +import type { Events } from "../../../shared/analytics" +import { analytics } from "../../analytics" + +type AnyProduceParams = ProcedureParams< + AnyRootConfig, + unknown, + unknown, + unknown, + unknown, + unknown, + unknown +> + +type SuccessArg = { + input: T["_input_out"] + ctx: T["_ctx_out"] + output: T["_output_out"] +} +type ErrorArg = { + input: T["_input_out"] + ctx: T["_ctx_out"] + error: TRPCError +} + +export function trackMiddleware< + T extends keyof Events, + Params extends AnyProduceParams, +>( + event: T, + ...[successFn, errorFn]: Events[T] extends undefined // this simplification needs a body, otherwise you can not differentiate between error and success + ? never + : [ + successFn: (arg: SuccessArg) => Events[T], + error?: (arg: ErrorArg) => Events[T], + ] +): MiddlewareFunction { + return async ({ next, ...ctx }) => { + const result = await next() + + try { + if (result.ok) { + const successPayload = successFn?.({ ...ctx, output: result.data }) + if (successPayload) { + void analytics.track(event, successPayload) + } + } else { + const errorPayload = errorFn?.({ ...ctx, error: result.error }) + if (errorPayload) { + void analytics.track(event, errorPayload) + } + } + } catch { + console.warn("Error in trackMiddleware", event) + } + + return result + } +} diff --git a/packages/extension/src/background/__new/middleware/session.ts b/packages/extension/src/background/__new/middleware/session.ts new file mode 100644 index 000000000..bdd7c5165 --- /dev/null +++ b/packages/extension/src/background/__new/middleware/session.ts @@ -0,0 +1,13 @@ +import { TRPCError } from "@trpc/server" + +import { middleware } from "../trpc" + +export const openSessionMiddleware = middleware(async ({ ctx, next }) => { + if (!(await ctx.services.wallet.isSessionOpen())) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: "Open session needed", + }) + } + return next() +}) diff --git a/packages/extension/src/background/__new/procedures/account/create.ts b/packages/extension/src/background/__new/procedures/account/create.ts new file mode 100644 index 000000000..e07f32aac --- /dev/null +++ b/packages/extension/src/background/__new/procedures/account/create.ts @@ -0,0 +1,49 @@ +import { z } from "zod" + +// TODO: ⬇ should be a service which get injected in ctx +import { tryToMintFeeToken } from "../../../../shared/devnet/mintFeeToken" +import { createWalletAccountSchema } from "../../../../shared/wallet.model" +import { trackMiddleware } from "../../middleware/analytics" +import { openSessionMiddleware } from "../../middleware/session" +import { extensionOnlyProcedure } from "../permissions" + +const createAccountInputSchema = z.object({ + networkId: z.string(), + type: z.union([z.literal("standard"), z.literal("multisig")]), +}) + +export const createAccountProcedure = extensionOnlyProcedure + .use(openSessionMiddleware) + .input(createAccountInputSchema) + .output(createWalletAccountSchema) + .use( + trackMiddleware( + "createAccount", + ({ output, input }) => ({ + status: "success", + networkId: output.networkId, + type: input.type, + }), + ({ input: { networkId, type }, error }) => ({ + status: "failure", + networkId, + type, + errorMessage: error.message, + }), + ), + ) + .mutation( + async ({ + input: { networkId: network, type }, + ctx: { + services: { wallet }, + }, + }) => { + const account = await wallet.newAccount(network, type) + + // NOTE: ⬇ should be a service + void tryToMintFeeToken(account) + + return account + }, + ) diff --git a/packages/extension/src/background/__new/procedures/account/deploy.ts b/packages/extension/src/background/__new/procedures/account/deploy.ts new file mode 100644 index 000000000..7d3609bcf --- /dev/null +++ b/packages/extension/src/background/__new/procedures/account/deploy.ts @@ -0,0 +1,14 @@ +import { baseWalletAccountSchema } from "../../../../shared/wallet.model" +import { deployAccountAction } from "../../../accountDeploy" +import { openSessionMiddleware } from "../../middleware/session" +import { extensionOnlyProcedure } from "../permissions" + +export const deployAccountProcedure = extensionOnlyProcedure + .use(openSessionMiddleware) + .input(baseWalletAccountSchema) + .mutation(async ({ input: data, ctx: { services } }) => { + await deployAccountAction({ + account: data, + actionQueue: services.actionQueue, + }) + }) diff --git a/packages/extension/src/background/__new/procedures/account/index.ts b/packages/extension/src/background/__new/procedures/account/index.ts new file mode 100644 index 000000000..cae8998de --- /dev/null +++ b/packages/extension/src/background/__new/procedures/account/index.ts @@ -0,0 +1,10 @@ +import { router } from "../../trpc" +import { createAccountProcedure } from "./create" +import { deployAccountProcedure } from "./deploy" +import { upgradeAccountProcedure } from "./upgrade" + +export const accountRouter = router({ + create: createAccountProcedure, + deploy: deployAccountProcedure, + upgrade: upgradeAccountProcedure, +}) diff --git a/packages/extension/src/background/__new/procedures/account/upgrade.ts b/packages/extension/src/background/__new/procedures/account/upgrade.ts new file mode 100644 index 000000000..a656e2acc --- /dev/null +++ b/packages/extension/src/background/__new/procedures/account/upgrade.ts @@ -0,0 +1,33 @@ +import { z } from "zod" + +import { + argentAccountTypeSchema, + baseWalletAccountSchema, +} from "../../../../shared/wallet.model" +import { upgradeAccount } from "../../../accountUpgrade" +import { openSessionMiddleware } from "../../middleware/session" +import { extensionOnlyProcedure } from "../permissions" + +const upgradeAccountSchema = z.object({ + account: baseWalletAccountSchema, + targetImplementationType: argentAccountTypeSchema.optional(), +}) +export const upgradeAccountProcedure = extensionOnlyProcedure + .use(openSessionMiddleware) + .input(upgradeAccountSchema) + .mutation( + async ({ + input: { account, targetImplementationType }, + ctx: { + services: { wallet, actionQueue }, + }, + }) => { + // TODO ⬇ should be a service + await upgradeAccount({ + account, + wallet, + targetImplementationType, + actionQueue, + }) + }, + ) diff --git a/packages/extension/src/background/__new/procedures/network/add.ts b/packages/extension/src/background/__new/procedures/network/add.ts new file mode 100644 index 000000000..e8a845c67 --- /dev/null +++ b/packages/extension/src/background/__new/procedures/network/add.ts @@ -0,0 +1,11 @@ +import { addNetwork, networkSchema } from "../../../../shared/network" +import { openSessionMiddleware } from "../../middleware/session" +import { extensionOnlyProcedure } from "../permissions" + +export const addNetworkProcedure = extensionOnlyProcedure + .use(openSessionMiddleware) + .input(networkSchema) + .mutation(async ({ input }) => { + await addNetwork(input) + return true + }) diff --git a/packages/extension/src/background/__new/procedures/network/index.ts b/packages/extension/src/background/__new/procedures/network/index.ts new file mode 100644 index 000000000..84b83e578 --- /dev/null +++ b/packages/extension/src/background/__new/procedures/network/index.ts @@ -0,0 +1,6 @@ +import { router } from "../../trpc" +import { addNetworkProcedure } from "./add" + +export const networkRouter = router({ + add: addNetworkProcedure, +}) diff --git a/packages/extension/src/background/__new/procedures/permissions.ts b/packages/extension/src/background/__new/procedures/permissions.ts new file mode 100644 index 000000000..20f8668e8 --- /dev/null +++ b/packages/extension/src/background/__new/procedures/permissions.ts @@ -0,0 +1,45 @@ +import { TRPCError } from "@trpc/server" + +import { procedure } from "../trpc" + +// TODO: ⬇ should be service +function getOrigin(url: string) { + const { origin } = new URL(url) + return origin +} +function matchOrigin(urlToMatch: string, url?: string) { + try { + const matchOrigin = getOrigin(urlToMatch) + if (!url) { + return false + } + + const origin = getOrigin(url) + return origin === matchOrigin + } catch { + return false + } +} + +export const publicProcedure = procedure + +export const extensionOnlyProcedure = publicProcedure.use( + async ({ ctx, next }) => { + const extensionUrl = chrome.runtime.getURL("") + const sender = ctx.sender + const senderUrl = sender?.url ?? sender?.origin + if (!sender || !matchOrigin(extensionUrl, senderUrl)) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Reserved for extension calls", + }) + } + + return next({ + ctx: { + ...ctx, + sender, // by passing it after checking, every method after this middleware will have a mandatory sender + }, + }) + }, +) diff --git a/packages/extension/src/background/__new/procedures/recovery/index.ts b/packages/extension/src/background/__new/procedures/recovery/index.ts new file mode 100644 index 000000000..e9acca882 --- /dev/null +++ b/packages/extension/src/background/__new/procedures/recovery/index.ts @@ -0,0 +1,8 @@ +import { router } from "../../trpc" +import { recoverBackupProcedure } from "./recoverBackup" +import { recoverSeedphraseProcedure } from "./recoverSeedphrase" + +export const recoveryRouter = router({ + recoverBackup: recoverBackupProcedure, + recoverSeedPhrase: recoverSeedphraseProcedure, +}) diff --git a/packages/extension/src/background/__new/procedures/recovery/recoverBackup.ts b/packages/extension/src/background/__new/procedures/recovery/recoverBackup.ts new file mode 100644 index 000000000..c72cb0233 --- /dev/null +++ b/packages/extension/src/background/__new/procedures/recovery/recoverBackup.ts @@ -0,0 +1,20 @@ +import { z } from "zod" + +import { extensionOnlyProcedure } from "../permissions" + +const recoverBackupSchema = z.object({ + backup: z.string(), +}) + +export const recoverBackupProcedure = extensionOnlyProcedure + .input(recoverBackupSchema) + .mutation( + async ({ + input: { backup }, + ctx: { + services: { wallet }, + }, + }) => { + await wallet.importBackup(backup) + }, + ) diff --git a/packages/extension/src/background/__new/procedures/recovery/recoverSeedphrase.ts b/packages/extension/src/background/__new/procedures/recovery/recoverSeedphrase.ts new file mode 100644 index 000000000..07da90069 --- /dev/null +++ b/packages/extension/src/background/__new/procedures/recovery/recoverSeedphrase.ts @@ -0,0 +1,41 @@ +import { compactDecrypt } from "jose" +import { z } from "zod" + +import { accountService } from "../../../../shared/account/service" +import { bytesToUft8 } from "../../../../shared/utils/encode" +import { getMessagingKeys } from "../../../keys/messagingKeys" +import { extensionOnlyProcedure } from "../permissions" + +const recoverSeedphraseSchema = z.object({ + jwe: z.string(), +}) + +const recoverSeedphraseResponseSchema = z.object({ + isSuccess: z.boolean(), +}) +export const recoverSeedphraseProcedure = extensionOnlyProcedure + .input(recoverSeedphraseSchema) + .output(recoverSeedphraseResponseSchema) + .mutation( + async ({ + input: { jwe }, + ctx: { + services: { wallet, transactionTracker }, + }, + }) => { + const messagingKeys = await getMessagingKeys() + + const { plaintext } = await compactDecrypt(jwe, messagingKeys.privateKey) + const { + seedPhrase, + newPassword, + }: { + seedPhrase: string + newPassword: string + } = JSON.parse(bytesToUft8(plaintext)) + + await wallet.restoreSeedPhrase(seedPhrase, newPassword) + void transactionTracker.loadHistory(await accountService.get()) + return { isSuccess: true } + }, + ) diff --git a/packages/extension/src/background/__new/router.ts b/packages/extension/src/background/__new/router.ts new file mode 100644 index 000000000..c81f6bb13 --- /dev/null +++ b/packages/extension/src/background/__new/router.ts @@ -0,0 +1,32 @@ +import { createChromeHandler } from "trpc-extension/adapter" + +import { globalActionQueueStore } from "../../shared/actionQueue/store" +import { ActionItem } from "../../shared/actionQueue/types" +import { getQueue } from "../actionQueue" +import { transactionTracker } from "../transactions/tracking" +import { walletSingleton } from "../walletSingleton" +import { accountRouter } from "./procedures/account" +import { networkRouter } from "./procedures/network" +import { recoveryRouter } from "./procedures/recovery" +import { router } from "./trpc" + +const appRouter = router({ + account: accountRouter, + network: networkRouter, + recovery: recoveryRouter, +}) + +export type AppRouter = typeof appRouter + +createChromeHandler({ + router: appRouter, + createContext: async ({ req: port }) => ({ + sender: port.sender, // changes on every request + services: { + // services can be shared accross requests, as we usually only handle one user at a time + wallet: walletSingleton, // wallet "service" is obviously way too big and should be split up + actionQueue: await getQueue(globalActionQueueStore), + transactionTracker, + }, + }), +}) diff --git a/packages/extension/src/background/__new/services/onboarding/implementation.test.ts b/packages/extension/src/background/__new/services/onboarding/implementation.test.ts new file mode 100644 index 000000000..4187cb4be --- /dev/null +++ b/packages/extension/src/background/__new/services/onboarding/implementation.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, test, vi } from "vitest" + +import type { KeyValueStorage } from "../../../../shared/storage" +import type { WalletStorageProps } from "../../../../shared/wallet/walletStore" +import OnboardingService from "./implementation" + +describe("OnboardingService", () => { + const makeService = () => { + const uiService = { + closePopup: vi.fn(), + createTab: vi.fn(), + focusTab: vi.fn(), + getPopup: vi.fn(), + getTab: vi.fn(), + hasPopup: vi.fn(), + hasTab: vi.fn(), + setDefaultPopup: vi.fn(), + unsetDefaultPopup: vi.fn(), + } + const walletStore = { + get: vi.fn(), + } as unknown as KeyValueStorage + const onboardingService = new OnboardingService(uiService, walletStore) + return { + onboardingService, + uiService, + walletStore, + } + } + test("getOnboardingComplete", async () => { + const { onboardingService, walletStore } = makeService() + await onboardingService.getOnboardingComplete() + expect(walletStore.get).toHaveBeenCalledWith("backup") + }) + describe("openOnboarding", async () => { + test("when there is no popup or tab", async () => { + const { onboardingService, uiService } = makeService() + const iconClickOpensOnboardingSpy = vi.spyOn( + onboardingService, + "iconClickOpensOnboarding", + ) + uiService.hasPopup.mockImplementationOnce(() => false) + uiService.hasTab.mockImplementationOnce(() => false) + await onboardingService.openOnboarding() + expect(iconClickOpensOnboardingSpy).toHaveBeenCalled() + expect(uiService.hasPopup).toHaveBeenCalled() + expect(uiService.closePopup).not.toHaveBeenCalled() + expect(uiService.hasTab).toHaveBeenCalled() + expect(uiService.createTab).toHaveBeenCalled() + expect(uiService.focusTab).not.toHaveBeenCalled() + }) + test("when there is a popup but no tab", async () => { + const { onboardingService, uiService } = makeService() + const iconClickOpensOnboardingSpy = vi.spyOn( + onboardingService, + "iconClickOpensOnboarding", + ) + uiService.hasPopup.mockImplementationOnce(() => true) + uiService.hasTab.mockImplementationOnce(() => false) + await onboardingService.openOnboarding() + expect(iconClickOpensOnboardingSpy).toHaveBeenCalled() + expect(uiService.hasPopup).toHaveBeenCalled() + expect(uiService.closePopup).toHaveBeenCalled() + expect(uiService.hasTab).toHaveBeenCalled() + expect(uiService.createTab).toHaveBeenCalled() + expect(uiService.focusTab).not.toHaveBeenCalled() + }) + test("when there is a tab but no popup", async () => { + const { onboardingService, uiService } = makeService() + const iconClickOpensOnboardingSpy = vi.spyOn( + onboardingService, + "iconClickOpensOnboarding", + ) + uiService.hasPopup.mockImplementationOnce(() => false) + uiService.hasTab.mockImplementationOnce(() => true) + await onboardingService.openOnboarding() + expect(iconClickOpensOnboardingSpy).toHaveBeenCalled() + expect(uiService.hasPopup).toHaveBeenCalled() + expect(uiService.closePopup).not.toHaveBeenCalled() + expect(uiService.hasTab).toHaveBeenCalled() + expect(uiService.createTab).not.toHaveBeenCalled() + expect(uiService.focusTab).toHaveBeenCalled() + }) + }) +}) diff --git a/packages/extension/src/background/__new/services/onboarding/implementation.ts b/packages/extension/src/background/__new/services/onboarding/implementation.ts new file mode 100644 index 000000000..269261de2 --- /dev/null +++ b/packages/extension/src/background/__new/services/onboarding/implementation.ts @@ -0,0 +1,38 @@ +import type { IUIService } from "../../../../shared/__new/services/ui/interface" +import type { KeyValueStorage } from "../../../../shared/storage" +import type { WalletStorageProps } from "../../../../shared/wallet/walletStore" +import type { IOnboardingService } from "./interface" + +export default class OnboardingService implements IOnboardingService { + constructor( + private uiService: IUIService, + private walletStore: KeyValueStorage, + ) {} + + async getOnboardingComplete() { + const value = await this.walletStore.get("backup") + return Boolean(value) + } + + async openOnboarding() { + this.iconClickOpensOnboarding() + const hasPopup = this.uiService.hasPopup() + if (hasPopup) { + this.uiService.closePopup() + } + const hasTab = await this.uiService.hasTab() + if (hasTab) { + await this.uiService.focusTab() + } else { + await this.uiService.createTab() + } + } + + iconClickOpensOnboarding() { + void this.uiService.unsetDefaultPopup() + } + + iconClickOpensPopup() { + void this.uiService.setDefaultPopup() + } +} diff --git a/packages/extension/src/background/__new/services/onboarding/index.ts b/packages/extension/src/background/__new/services/onboarding/index.ts new file mode 100644 index 000000000..1d6fabf43 --- /dev/null +++ b/packages/extension/src/background/__new/services/onboarding/index.ts @@ -0,0 +1,17 @@ +import browser from "webextension-polyfill" + +import { uiService } from "../../../../shared/__new/services/ui" +import { old_walletStore } from "../../../../shared/wallet/walletStore" +import OnboardingService from "./implementation" +import OnboardingWorker from "./worker/implementation" + +export const onboardingService = new OnboardingService( + uiService, + old_walletStore, +) + +export const onboardingWorker = new OnboardingWorker( + onboardingService, + old_walletStore, + browser, +) diff --git a/packages/extension/src/background/__new/services/onboarding/interface.ts b/packages/extension/src/background/__new/services/onboarding/interface.ts new file mode 100644 index 000000000..f8e873006 --- /dev/null +++ b/packages/extension/src/background/__new/services/onboarding/interface.ts @@ -0,0 +1,13 @@ +export interface IOnboardingService { + /** whether user has an onboarded wallet */ + getOnboardingComplete(): Promise + + /** opens the onboarding flow */ + openOnboarding(): Promise + + /** whether clicking extension icon opens onboarding */ + iconClickOpensOnboarding(): void + + /** whether clicking extension icon opens popup */ + iconClickOpensPopup(): void +} diff --git a/packages/extension/src/background/__new/services/onboarding/worker/implementation.test.ts b/packages/extension/src/background/__new/services/onboarding/worker/implementation.test.ts new file mode 100644 index 000000000..2c4a3e05b --- /dev/null +++ b/packages/extension/src/background/__new/services/onboarding/worker/implementation.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, test, vi } from "vitest" + +import type { KeyValueStorage } from "../../../../../shared/storage" +import type { WalletStorageProps } from "../../../../../shared/wallet/walletStore" +import OnboardingWorker from "./implementation" + +describe("OnboardingWorker", () => { + const makeService = () => { + const onboardingService = { + getOnboardingComplete: vi.fn(), + iconClickOpensPopup: vi.fn(), + iconClickOpensOnboarding: vi.fn(), + openOnboarding: vi.fn(), + } + const walletStore = { + subscribe: vi.fn(), + } as unknown as KeyValueStorage + const browser = { + runtime: { + onInstalled: { + addListener: vi.fn(), + }, + }, + browserAction: { + onClicked: { + addListener: vi.fn(), + }, + }, + } + const onboardingWorker = new OnboardingWorker( + onboardingService, + walletStore, + browser, + ) + return { + onboardingWorker, + onboardingService, + walletStore, + browser, + } + } + test("it should add listeners", async () => { + const { onboardingService, walletStore, browser } = makeService() + onboardingService.getOnboardingComplete.mockImplementationOnce( + async () => false, + ) + expect(browser.runtime.onInstalled.addListener).toHaveBeenCalled() + expect(browser.browserAction.onClicked.addListener).toHaveBeenCalled() + expect(walletStore.subscribe).toHaveBeenCalled() + expect(onboardingService.getOnboardingComplete).toHaveBeenCalled() + await Promise.resolve() + expect(onboardingService.iconClickOpensOnboarding).toHaveBeenCalled() + }) +}) diff --git a/packages/extension/src/background/__new/services/onboarding/worker/implementation.ts b/packages/extension/src/background/__new/services/onboarding/worker/implementation.ts new file mode 100644 index 000000000..e2b1c3a6d --- /dev/null +++ b/packages/extension/src/background/__new/services/onboarding/worker/implementation.ts @@ -0,0 +1,62 @@ +import type { KeyValueStorage } from "../../../../../shared/storage" +import type { StorageChange } from "../../../../../shared/storage/types" +import type { DeepPick } from "../../../../../shared/types/deepPick" +import type { WalletStorageProps } from "../../../../../shared/wallet/walletStore" +import type { IOnboardingService } from "../interface" + +type MinimalBrowser = DeepPick< + typeof chrome, + "runtime.onInstalled.addListener" | "browserAction.onClicked.addListener" +> + +export default class OnboardingWorker { + constructor( + private onboardingService: IOnboardingService, + private walletStore: KeyValueStorage, + private browser: MinimalBrowser, + ) { + this.browser.runtime.onInstalled.addListener(this.onInstalled.bind(this)) + this.browser.browserAction.onClicked.addListener( + this.onExtensionIconClick.bind(this), + ) + this.walletStore.subscribe( + "backup", + this.onWalletStoreBackupChange.bind(this), + ) + void (async () => { + /** initialise what happens when user clicks icon */ + const onboardingComplete = + await this.onboardingService.getOnboardingComplete() + if (onboardingComplete) { + this.onboardingService.iconClickOpensPopup() + } else { + this.onboardingService.iconClickOpensOnboarding() + } + })() + } + + private onInstalled(details: chrome.runtime.InstalledDetails) { + if (details.reason === chrome.runtime.OnInstalledReason.INSTALL) { + void this.onboardingService.openOnboarding() + } + } + + /** Icon click event that fires only when `iconClickOpensOnboarding` is set */ + private onExtensionIconClick() { + void this.onboardingService.openOnboarding() + } + + private onWalletStoreBackupChange( + _value: string | undefined, + { oldValue, newValue }: StorageChange, + ) { + if (oldValue === undefined && newValue !== undefined) { + /** New wallet created - onboarding done */ + this.onboardingService.iconClickOpensPopup() + } else if (oldValue !== undefined && newValue === undefined) { + /** Wallet destroyed - start onboarding again */ + this.onboardingService.iconClickOpensOnboarding() + void this.onboardingService.openOnboarding() + } + } +} diff --git a/packages/extension/src/background/__new/trpc.ts b/packages/extension/src/background/__new/trpc.ts new file mode 100644 index 000000000..019c09568 --- /dev/null +++ b/packages/extension/src/background/__new/trpc.ts @@ -0,0 +1,24 @@ +import { initTRPC } from "@trpc/server" + +import { ActionItem } from "../../shared/actionQueue/types" +import { Queue } from "../actionQueue" +import { TransactionTracker } from "../transactions/tracking" +import { Wallet } from "../wallet" + +interface Context { + sender?: chrome.runtime.MessageSender + services: { + wallet: Wallet + actionQueue: Queue + transactionTracker: TransactionTracker + } +} + +const t = initTRPC.context().create({ + isServer: false, + allowOutsideOfServer: true, +}) + +export const router = t.router +export const procedure = t.procedure +export const middleware = t.middleware diff --git a/packages/extension/src/background/accountDeploy.ts b/packages/extension/src/background/accountDeploy.ts index 900046d7c..916e5c4cf 100644 --- a/packages/extension/src/background/accountDeploy.ts +++ b/packages/extension/src/background/accountDeploy.ts @@ -17,6 +17,16 @@ export const deployAccountAction = async ({ }) } +export const deployMultisigAction = async ({ + actionQueue, + account, +}: IDeployAccount) => { + await actionQueue.push({ + type: "DEPLOY_MULTISIG_ACTION", + payload: account, + }) +} + export const isAccountDeployed = async ( account: WalletAccount, getClassAt: (address: string, blockIdentifier?: unknown) => Promise, diff --git a/packages/extension/src/background/accountMessaging.ts b/packages/extension/src/background/accountMessaging.ts index 72e41642e..c0c18c3ec 100644 --- a/packages/extension/src/background/accountMessaging.ts +++ b/packages/extension/src/background/accountMessaging.ts @@ -1,13 +1,10 @@ import { constants, number } from "starknet" -import { getAccounts, removeAccount } from "../shared/account/store" -import { tryToMintFeeToken } from "../shared/devnet/mintFeeToken" +import { accountService } from "../shared/account/service" import { AccountMessage } from "../shared/messages/AccountMessage" import { isEqualAddress } from "../ui/services/addresses" -import { deployAccountAction } from "./accountDeploy" import { upgradeAccount } from "./accountUpgrade" import { sendMessageToUi } from "./activeTabs" -import { analytics } from "./analytics" import { HandleMessage, UnhandledMessage } from "./background" import { encryptForUi } from "./crypto" import { addTransaction } from "./transactions/store" @@ -16,80 +13,8 @@ export const handleAccountMessage: HandleMessage = async ({ msg, background: { wallet, actionQueue }, messagingKeys: { privateKey }, - respond, }) => { switch (msg.type) { - case "GET_ACCOUNTS": { - return sendMessageToUi({ - type: "GET_ACCOUNTS_RES", - data: await getAccounts(msg.data?.showHidden ? () => true : undefined), - }) - } - - case "CONNECT_ACCOUNT": { - // Select an Account of BaseWalletAccount type - const selectedAccount = await wallet.getSelectedAccount() - - return respond({ - type: "CONNECT_ACCOUNT_RES", - data: selectedAccount, - }) - } - - case "NEW_ACCOUNT": { - if (!(await wallet.isSessionOpen())) { - throw Error("you need an open session") - } - - const network = msg.data - try { - const account = await wallet.newAccount(network) - - tryToMintFeeToken(account) - - analytics.track("createAccount", { - status: "success", - networkId: network, - }) - - const accounts = await getAccounts() - - return sendMessageToUi({ - type: "NEW_ACCOUNT_RES", - data: { - account, - accounts, - }, - }) - } catch (exception) { - const error = `${exception}` - - analytics.track("createAccount", { - status: "failure", - networkId: network, - errorMessage: error, - }) - - return sendMessageToUi({ - type: "NEW_ACCOUNT_REJ", - data: { error }, - }) - } - } - - case "DEPLOY_ACCOUNT": { - try { - await deployAccountAction({ - account: msg.data, - actionQueue, - }) - - return sendMessageToUi({ type: "DEPLOY_ACCOUNT_RES" }) - } catch (e) { - return sendMessageToUi({ type: "DEPLOY_ACCOUNT_REJ" }) - } - } - case "GET_SELECTED_ACCOUNT": { const selectedAccount = await wallet.getSelectedAccount() return sendMessageToUi({ @@ -117,7 +42,7 @@ export const handleAccountMessage: HandleMessage = async ({ const account = msg.data const fullAccount = await wallet.getAccount(account) const { txHash } = await wallet.redeployAccount(fullAccount) - addTransaction({ + void addTransaction({ hash: txHash, account: fullAccount, meta: { title: "Redeploy wallet", type: "DEPLOY_ACCOUNT" }, @@ -136,7 +61,7 @@ export const handleAccountMessage: HandleMessage = async ({ case "DELETE_ACCOUNT": { try { - await removeAccount(msg.data) + await accountService.remove(msg.data) return sendMessageToUi({ type: "DELETE_ACCOUNT_RES" }) } catch { return sendMessageToUi({ type: "DELETE_ACCOUNT_REJ" }) @@ -149,7 +74,7 @@ export const handleAccountMessage: HandleMessage = async ({ } const encryptedPrivateKey = await encryptForUi( - await wallet.exportPrivateKey(msg.data.account), + await wallet.getPrivateKey(msg.data.account), msg.data.encryptedSecret, privateKey, ) @@ -161,11 +86,11 @@ export const handleAccountMessage: HandleMessage = async ({ } case "GET_PUBLIC_KEY": { - const publicKey = await wallet.getPublicKey(msg.data) + const { publicKey, account } = await wallet.getPublicKey(msg.data) return sendMessageToUi({ type: "GET_PUBLIC_KEY_RES", - data: { publicKey }, + data: { publicKey, account }, }) } @@ -186,6 +111,22 @@ export const handleAccountMessage: HandleMessage = async ({ }) } + case "GET_NEXT_PUBLIC_KEY": { + try { + const { publicKey } = await wallet.getNextPublicKey(msg.data.networkId) + + return sendMessageToUi({ + type: "GET_NEXT_PUBLIC_KEY_RES", + data: { publicKey }, + }) + } catch (e) { + console.error(e) + return sendMessageToUi({ + type: "GET_NEXT_PUBLIC_KEY_REJ", + }) + } + } + case "DEPLOY_ACCOUNT_ACTION_FAILED": { return await actionQueue.remove(msg.data.actionHash) } @@ -193,22 +134,23 @@ export const handleAccountMessage: HandleMessage = async ({ case "ACCOUNT_CHANGE_GUARDIAN": { try { const { account, guardian } = msg.data + + const newGuardian = number.hexToDecimalString(guardian) + await actionQueue.push({ type: "TRANSACTION", payload: { transactions: { contractAddress: account.address, entrypoint: "changeGuardian", - calldata: [ - number.hexToDecimalString( - guardian || constants.ZERO.toString(), - ), - ], + calldata: [newGuardian], }, meta: { isChangeGuardian: true, title: "Change account guardian", - type: "INVOKE_FUNCTION", + type: number.toBN(newGuardian).isZero() // if guardian is 0, it's a remove guardian action + ? "REMOVE_ARGENT_SHIELD" + : "ADD_ARGENT_SHIELD", }, }, }) @@ -296,7 +238,7 @@ export const handleAccountMessage: HandleMessage = async ({ throw Error("no account selected") } - const publicKey = await wallet.getPublicKey(account) + const { publicKey } = await wallet.getPublicKey(account) if ( selectedAccount.guardian && diff --git a/packages/extension/src/background/accountUpgrade.ts b/packages/extension/src/background/accountUpgrade.ts index 07fa7cfc7..1e9150062 100644 --- a/packages/extension/src/background/accountUpgrade.ts +++ b/packages/extension/src/background/accountUpgrade.ts @@ -2,7 +2,6 @@ import { stark } from "starknet" import { ActionItem } from "../shared/actionQueue/types" import { getNetwork } from "../shared/network" -import { mapArgentAccountTypeToImplementationKey } from "../shared/network/utils" import { ArgentAccountType, BaseWalletAccount } from "../shared/wallet.model" import { Queue } from "./actionQueue" import { Wallet } from "./wallet" @@ -29,13 +28,12 @@ export const upgradeAccount = async ({ fullAccount.network.id, ) - if (!newImplementation) { + if (!newImplementation || !newImplementation.standard) { throw "Cannot upgrade account without a new contract implementation" } const implementationClassHash = - newImplementation[mapArgentAccountTypeToImplementationKey(accountType)] ?? - newImplementation.argentAccount + newImplementation[accountType] ?? newImplementation.standard const calldata = stark.compileCalldata({ implementation: implementationClassHash, diff --git a/packages/extension/src/background/actionHandlers.ts b/packages/extension/src/background/actionHandlers.ts index 6d397bf56..79efcd132 100644 --- a/packages/extension/src/background/actionHandlers.ts +++ b/packages/extension/src/background/actionHandlers.ts @@ -1,4 +1,4 @@ -import { getAccounts } from "../shared/account/store" +import { accountService } from "../shared/account/service" import { ActionItem, ExtQueueItem } from "../shared/actionQueue/types" import { MessageType } from "../shared/messages" import { addNetwork, getNetworks } from "../shared/network" @@ -8,6 +8,7 @@ import { assertNever } from "../ui/services/assertNever" import { accountDeployAction } from "./accountDeployAction" import { analytics } from "./analytics" import { BackgroundService } from "./background" +import { multisigDeployAction } from "./multisigDeployAction" import { openUi } from "./openUi" import { executeTransactionAction } from "./transactions/transactionExecution" import { udcDeclareContract, udcDeployContract } from "./udcAction" @@ -25,11 +26,11 @@ export const handleActionApproval = async ( const selectedAccount = await wallet.getSelectedAccount() if (!selectedAccount) { - openUi() + void openUi() return } - analytics.track("preauthorizeDapp", { + void analytics.track("preauthorizeDapp", { host, networkId: selectedAccount.networkId, }) @@ -59,7 +60,7 @@ export const handleActionApproval = async ( try { const txHash = await accountDeployAction(action, background) - analytics.track("deployAccount", { + void analytics.track("deployAccount", { status: "success", trigger: "sign", networkId: action.payload.networkId, @@ -75,7 +76,7 @@ export const handleActionApproval = async ( error = `${error}\n\nA 403 error means there's already something running on the selected port. On macOS, AirPlay is using port 5000 by default, so please try running your node on another port and changing the port in Argent X settings.` } - analytics.track("deployAccount", { + void analytics.track("deployAccount", { status: "failure", networkId: action.payload.networkId, errorMessage: `${error}`, @@ -88,6 +89,39 @@ export const handleActionApproval = async ( } } + case "DEPLOY_MULTISIG_ACTION": { + try { + const txHash = await multisigDeployAction(action, background) + + void analytics.track("deployMultisig", { + status: "success", + trigger: "transaction", + networkId: action.payload.networkId, + }) + + return { + type: "DEPLOY_MULTISIG_ACTION_SUBMITTED", + data: { txHash, actionHash }, + } + } catch (exception: unknown) { + let error = `${exception}` + if (error.includes("403")) { + error = `${error}\n\nA 403 error means there's already something running on the selected port. On macOS, AirPlay is using port 5000 by default, so please try running your node on another port and changing the port in Argent X settings.` + } + + void analytics.track("deployMultisig", { + status: "failure", + networkId: action.payload.networkId, + errorMessage: `${error}`, + }) + + return { + type: "DEPLOY_MULTISIG_ACTION_FAILED", + data: { actionHash, error: `${error}` }, + } + } + } + case "SIGN": { const typedData = action.payload if (!(await wallet.isSessionOpen())) { @@ -95,13 +129,12 @@ export const handleActionApproval = async ( } const starknetAccount = await wallet.getSelectedStarknetAccount() - const [r, s] = await starknetAccount.signMessage(typedData) + const signature = await starknetAccount.signMessage(typedData) return { type: "SIGNATURE_SUCCESS", data: { - r: r.toString(), - s: s.toString(), + signature, actionHash, }, } @@ -114,21 +147,6 @@ export const handleActionApproval = async ( } } - case "REQUEST_ADD_CUSTOM_NETWORK": { - try { - await addNetwork(action.payload) - return { - type: "APPROVE_REQUEST_ADD_CUSTOM_NETWORK", - data: { actionHash }, - } - } catch (error) { - return { - type: "REJECT_REQUEST_ADD_CUSTOM_NETWORK", - data: { actionHash }, - } - } - } - case "REQUEST_SWITCH_CUSTOM_NETWORK": { try { const networks = await getNetworks() @@ -141,7 +159,7 @@ export const handleActionApproval = async ( throw Error(`Network with chainId ${chainId} not found`) } - const accountsOnNetwork = await getAccounts((account) => { + const accountsOnNetwork = await accountService.get((account) => { return account.networkId === network.id && !account.hidden }) @@ -265,23 +283,23 @@ export const handleActionRejection = async ( } } - case "SIGN": { + case "DEPLOY_MULTISIG_ACTION": { return { - type: "SIGNATURE_FAILURE", + type: "DEPLOY_MULTISIG_ACTION_FAILED", data: { actionHash }, } } - case "REQUEST_TOKEN": { + case "SIGN": { return { - type: "REJECT_REQUEST_TOKEN", + type: "SIGNATURE_FAILURE", data: { actionHash }, } } - case "REQUEST_ADD_CUSTOM_NETWORK": { + case "REQUEST_TOKEN": { return { - type: "REJECT_REQUEST_ADD_CUSTOM_NETWORK", + type: "REJECT_REQUEST_TOKEN", data: { actionHash }, } } diff --git a/packages/extension/src/background/crypto.ts b/packages/extension/src/background/crypto.ts index bf3dc39e7..3e1922637 100644 --- a/packages/extension/src/background/crypto.ts +++ b/packages/extension/src/background/crypto.ts @@ -1,5 +1,6 @@ import { EncryptJWT, KeyLike, compactDecrypt, importJWK } from "jose" -import { encode } from "starknet" + +import { bytesToUft8 } from "../shared/utils/encode" export const encryptForUi = async ( value: string, @@ -8,7 +9,7 @@ export const encryptForUi = async ( ) => { const { plaintext } = await compactDecrypt(encryptedSecret, privateKey) - const jwk = JSON.parse(encode.arrayBufferToString(plaintext)) + const jwk = JSON.parse(bytesToUft8(plaintext)) const symmetricSecret = await importJWK(jwk) return await new EncryptJWT({ value }) diff --git a/packages/extension/src/background/index.ts b/packages/extension/src/background/index.ts index 0746e35e5..ebb8e4b51 100644 --- a/packages/extension/src/background/index.ts +++ b/packages/extension/src/background/index.ts @@ -1,20 +1,20 @@ +import "./__new/router" + import { StarknetMethodArgumentsSchemas } from "@argent/x-window" import browser from "webextension-polyfill" -import { accountStore, getAccounts } from "../shared/account/store" +import { accountService } from "../shared/account/service" import { globalActionQueueStore } from "../shared/actionQueue/store" import { ActionItem } from "../shared/actionQueue/types" import { MessageType, messageStream } from "../shared/messages" -import { getNetwork } from "../shared/network" +import { multisigTracker } from "../shared/multisig/tracking" import { isPreAuthorized, migratePreAuthorizations, } from "../shared/preAuthorizations" import { delay } from "../shared/utils/delay" import { migrateWallet } from "../shared/wallet/storeMigration" -import { walletStore } from "../shared/wallet/walletStore" import { handleAccountMessage } from "./accountMessaging" -import { loadContracts } from "./accounts" import { handleActionMessage } from "./actionMessaging" import { getQueue } from "./actionQueue" import { @@ -30,13 +30,14 @@ import { } from "./background" import { getMessagingKeys } from "./keys/messagingKeys" import { handleMiscellaneousMessage } from "./miscellaneousMessaging" +import { handleMultisigMessage } from "./multisigMessaging" +import { networkService } from "./network/network.service" import { handleNetworkMessage } from "./networkMessaging" import { initOnboarding } from "./onboarding" import { getOriginFromSender, handlePreAuthorizationMessage, } from "./preAuthorizationMessaging" -import { handleRecoveryMessage } from "./recoveryMessaging" import { handleSessionMessage } from "./sessionMessaging" import { handleShieldMessage } from "./shieldMessaging" import { handleTokenMessaging } from "./tokenMessaging" @@ -44,52 +45,103 @@ import { initBadgeText } from "./transactions/badgeText" import { transactionTracker } from "./transactions/tracking" import { handleTransactionMessage } from "./transactions/transactionMessaging" import { handleUdcMessaging } from "./udcMessaging" -import { Wallet, sessionStore } from "./wallet" +import { walletSingleton } from "./walletSingleton" const DEFAULT_POLLING_INTERVAL = 15 const LOCAL_POLLING_INTERVAL = 5 -browser.alarms.create("core:transactionTracker:history", { +const enum ALARM_NAMES { + TRANSACTION_TRACKER_HISTORY = "core:transactionTracker:history", + TRANSACTION_TRACKER_UPDATE = "core:transactionTracker:update", + MULTISIG_ACCOUNT_UPDATE = "core:multisig:updateDataForAccounts", + MULTISIG_PENDING_UPDATE = "core:multisig:updateDataForPendingMultisig", + MULTISIG_TRANSACTION_TRACKER = "core:multisig:transactionTracker", + NETWORK_STATUS_TRACKER = "core:networkStatusTracker:update", +} + +browser.alarms.create(ALARM_NAMES.TRANSACTION_TRACKER_HISTORY, { periodInMinutes: 5, // fetch history transactions every 5 minutes from voyager }) -browser.alarms.create("core:transactionTracker:update", { +browser.alarms.create(ALARM_NAMES.TRANSACTION_TRACKER_UPDATE, { periodInMinutes: 1, // fetch transaction updates of existing transactions every minute from onchain }) +browser.alarms.create(ALARM_NAMES.MULTISIG_ACCOUNT_UPDATE, { + periodInMinutes: 5, // fetch multisig updates of existing multisigs every 5 minutes from backend +}) +browser.alarms.create(ALARM_NAMES.MULTISIG_PENDING_UPDATE, { + periodInMinutes: 3, // fetch pending multisig updates of existing multisigs every 3 minutes from backend +}) +browser.alarms.create(ALARM_NAMES.MULTISIG_TRANSACTION_TRACKER, { + periodInMinutes: 2, // fetch transaction updates of existing multisig every 2 minutes from backend +}) + +// eslint-disable-next-line @typescript-eslint/no-misused-promises browser.alarms.onAlarm.addListener(async (alarm) => { - if (alarm.name === "core:transactionTracker:history") { - console.info("~> fetching transaction history") - await transactionTracker.loadHistory(await getAccounts()) - } - if (alarm.name === "core:transactionTracker:update") { - console.info("~> fetching transaction updates") - let inFlightTransactions = await transactionTracker.update() - // the config below will run transaction updates 4x per minute, if there are in-flight transactions - // By default it will update on second 0, 15, 30 and 45 but by updating WAIT_TIME we can change the number of executions - const maxExecutionTimeInMs = 60000 // 1 minute max execution time - let transactionPollingIntervalInS = DEFAULT_POLLING_INTERVAL - const startTime = Date.now() - - while ( - inFlightTransactions.length > 0 && - Date.now() - startTime < maxExecutionTimeInMs - ) { - const localTransaction = inFlightTransactions.find( - (tx) => tx.account.networkId === "localhost", - ) - if (localTransaction) { - transactionPollingIntervalInS = LOCAL_POLLING_INTERVAL - } else { - transactionPollingIntervalInS = DEFAULT_POLLING_INTERVAL + switch (alarm.name) { + case ALARM_NAMES.TRANSACTION_TRACKER_HISTORY: { + console.info("~> fetching transaction history") + await transactionTracker.loadHistory(await accountService.get()) + break + } + + case ALARM_NAMES.MULTISIG_ACCOUNT_UPDATE: { + console.info("~> fetching multisig account updates") + await multisigTracker.updateDataForAccounts() + break + } + + case ALARM_NAMES.MULTISIG_PENDING_UPDATE: { + console.info("~> fetching pending multisig account updates") + await multisigTracker.updateDataForPendingMultisig() + break + } + + case ALARM_NAMES.MULTISIG_TRANSACTION_TRACKER: { + console.info("~> fetching multisig transaction updates") + await multisigTracker.updateTransactions() + break + } + + case ALARM_NAMES.TRANSACTION_TRACKER_UPDATE: { + console.info("~> fetching transaction updates") + let inFlightTransactions = await transactionTracker.update() + // the config below will run transaction updates 4x per minute, if there are in-flight transactions + // By default it will update on second 0, 15, 30 and 45 but by updating WAIT_TIME we can change the number of executions + const maxExecutionTimeInMs = 60000 // 1 minute max execution time + let transactionPollingIntervalInS = DEFAULT_POLLING_INTERVAL + const startTime = Date.now() + + while ( + inFlightTransactions.length > 0 && + Date.now() - startTime < maxExecutionTimeInMs + ) { + const localTransaction = inFlightTransactions.find( + (tx) => tx.account.networkId === "localhost", + ) + if (localTransaction) { + transactionPollingIntervalInS = LOCAL_POLLING_INTERVAL + } else { + transactionPollingIntervalInS = DEFAULT_POLLING_INTERVAL + } + console.info( + `~> waiting ${transactionPollingIntervalInS}s for transaction updates`, + ) + await delay(transactionPollingIntervalInS * 1000) + console.info( + "~> fetching transaction updates as pending transactions were detected", + ) + inFlightTransactions = await transactionTracker.update() } - console.info( - `~> waiting ${transactionPollingIntervalInS}s for transaction updates`, - ) - await delay(transactionPollingIntervalInS * 1000) - console.info( - "~> fetching transaction updates as pending transactions were detected", - ) - inFlightTransactions = await transactionTracker.update() + break + } + + case ALARM_NAMES.NETWORK_STATUS_TRACKER: { + await networkService.updateStatuses() + break } + + default: + break } }) @@ -105,15 +157,16 @@ const handlers = [ handleMiscellaneousMessage, handleNetworkMessage, handlePreAuthorizationMessage, - handleRecoveryMessage, handleSessionMessage, handleTransactionMessage, handleTokenMessaging, handleUdcMessaging, handleShieldMessage, + handleMultisigMessage, ] as Array> -getAccounts() +accountService + .get() .then((x) => transactionTracker.loadHistory(x)) .catch(() => console.warn("failed to load transaction history")) @@ -141,7 +194,6 @@ const safeMessages: MessageType["type"][] = [ "REJECT_REQUEST_SWITCH_CUSTOM_NETWORK", "CONNECT_DAPP_RES", "CONNECT_ACCOUNT_RES", - "CONNECT_ACCOUNT", "REJECT_PREAUTHORIZATION", "REQUEST_DECLARE_CONTRACT_RES", "DECLARE_CONTRACT_ACTION_FAILED", @@ -152,7 +204,6 @@ const safeIfPreauthorizedMessages: MessageType["type"][] = [ "EXECUTE_TRANSACTION", "SIGN_MESSAGE", "REQUEST_TOKEN", - "REQUEST_ADD_CUSTOM_NETWORK", "REQUEST_SWITCH_CUSTOM_NETWORK", "REQUEST_DECLARE_CONTRACT", ] @@ -165,18 +216,10 @@ const handleMessage = async ( const messagingKeys = await getMessagingKeys() - const wallet = new Wallet( - walletStore, - accountStore, - sessionStore, - loadContracts, - getNetwork, - ) - const actionQueue = await getQueue(globalActionQueueStore) const background: BackgroundService = { - wallet, + wallet: walletSingleton, transactionTracker, actionQueue, } @@ -186,7 +229,7 @@ const handleMessage = async ( const origin = getOriginFromSender(sender) const isSafeOrigin = Boolean(origin === safeOrigin) - const currentAccount = await wallet.getSelectedAccount() + const currentAccount = await walletSingleton.getSelectedAccount() const senderIsPreauthorized = !!currentAccount && (await isPreAuthorized(currentAccount, origin)) @@ -237,6 +280,7 @@ const handleMessage = async ( } browser.runtime.onConnect.addListener((port) => { + // eslint-disable-next-line @typescript-eslint/no-misused-promises port.onMessage.addListener(async (msg: MessageType, port) => { const sender = port.sender if (sender) { @@ -272,6 +316,7 @@ browser.runtime.onConnect.addListener((port) => { }) }) +// eslint-disable-next-line @typescript-eslint/no-misused-promises messageStream.subscribe(handleMessage) // open onboarding flow on initial install diff --git a/packages/extension/src/background/miscellaneousMessaging.ts b/packages/extension/src/background/miscellaneousMessaging.ts index cb970a5f4..35ecb7ed9 100644 --- a/packages/extension/src/background/miscellaneousMessaging.ts +++ b/packages/extension/src/background/miscellaneousMessaging.ts @@ -17,10 +17,10 @@ export const handleMiscellaneousMessage: HandleMessage< case "RESET_ALL": { try { - browser.storage.local.clear() - browser.storage.sync.clear() - browser.storage.managed.clear() - browser.storage.session.clear() + await browser.storage.local.clear() + await browser.storage.sync.clear() + await browser.storage.managed.clear() + await browser.storage.session.clear() } catch { // Ignore browser.storage.session error "This is a read-only store" } diff --git a/packages/extension/src/background/multisigDeployAction.ts b/packages/extension/src/background/multisigDeployAction.ts new file mode 100644 index 000000000..1765722ae --- /dev/null +++ b/packages/extension/src/background/multisigDeployAction.ts @@ -0,0 +1,64 @@ +import { number } from "starknet" + +import { ExtQueueItem } from "../shared/actionQueue/types" +import { BaseWalletAccount } from "../shared/wallet.model" +import { BackgroundService } from "./background" +import { addTransaction } from "./transactions/store" +import { checkTransactionHash } from "./transactions/transactionExecution" +import { argentMaxFee } from "./utils/argentMaxFee" + +type DeployMultisigAction = ExtQueueItem<{ + type: "DEPLOY_MULTISIG_ACTION" + payload: BaseWalletAccount +}> + +export const multisigDeployAction = async ( + { payload: baseAccount }: DeployMultisigAction, + { wallet }: BackgroundService, +) => { + if (!(await wallet.isSessionOpen())) { + throw Error("you need an open session") + } + const selectedMultisig = await wallet.getMultisigAccount(baseAccount) + + const multisigNeedsDeploy = selectedMultisig.needsDeploy + + if (!multisigNeedsDeploy) { + throw Error("Account already deployed") + } + + let maxFee: string + + try { + const { suggestedMaxFee } = await wallet.getAccountDeploymentFee( + selectedMultisig, + ) + + maxFee = argentMaxFee(suggestedMaxFee) + } catch (error) { + const fallbackPrice = number.toBN(10e14) + maxFee = argentMaxFee(fallbackPrice) + } + + const { account, txHash } = await wallet.deployAccount(selectedMultisig, { + maxFee, + }) + + if (!checkTransactionHash(txHash)) { + throw Error( + "Deploy Multisig Transaction could not be added to the sequencer", + ) + } + + await addTransaction({ + hash: txHash, + account, + meta: { + title: "Activate Multisig", + isDeployAccount: true, + type: "DEPLOY_ACCOUNT", + }, + }) + + return txHash +} diff --git a/packages/extension/src/background/multisigMessaging.ts b/packages/extension/src/background/multisigMessaging.ts new file mode 100644 index 000000000..78ebd19aa --- /dev/null +++ b/packages/extension/src/background/multisigMessaging.ts @@ -0,0 +1,260 @@ +import { utils } from "ethers" +import { stark } from "starknet" + +import { tryToMintFeeToken } from "../shared/devnet/mintFeeToken" +import { MultisigMessage } from "../shared/messages/MultisigMessage" +import { MultisigAccount } from "../shared/multisig/account" +import { getMultisigAccounts } from "../shared/multisig/utils/baseMultisig" +import { deployMultisigAction } from "./accountDeploy" +import { sendMessageToUi } from "./activeTabs" +import { analytics } from "./analytics" +import { HandleMessage, UnhandledMessage } from "./background" + +export const handleMultisigMessage: HandleMessage = async ({ + msg, + background: { wallet, actionQueue }, +}) => { + switch (msg.type) { + case "NEW_MULTISIG_ACCOUNT": { + if (!(await wallet.isSessionOpen())) { + throw Error("you need an open session") + } + + const { networkId, signers, threshold, creator, publicKey } = msg.data + try { + const account = await wallet.newAccount(networkId, "multisig", { + signers, + threshold, + creator, + publicKey, + }) + tryToMintFeeToken(account) + + analytics.track("createAccount", { + status: "success", + networkId, + type: "multisig", + }) + + const accounts = await getMultisigAccounts() + + return sendMessageToUi({ + type: "NEW_MULTISIG_ACCOUNT_RES", + data: { + account, + accounts, + }, + }) + } catch (exception) { + const error = `${exception}` + + analytics.track("createAccount", { + status: "failure", + networkId: networkId, + type: "multisig", + errorMessage: error, + }) + + return sendMessageToUi({ + type: "NEW_MULTISIG_ACCOUNT_REJ", + data: { error }, + }) + } + } + + case "DEPLOY_MULTISIG": { + try { + await deployMultisigAction({ + account: msg.data, + actionQueue, + }) + + return sendMessageToUi({ type: "DEPLOY_MULTISIG_RES" }) + } catch (e) { + return sendMessageToUi({ type: "DEPLOY_MULTISIG_REJ" }) + } + } + + case "NEW_PENDING_MULTISIG": { + if (!(await wallet.isSessionOpen())) { + throw Error("you need an open session") + } + + const { networkId } = msg.data + try { + const pendingMultisig = await wallet.newPendingMultisig(networkId) + + // TODO: Add tracking + // analytics.track("createAccount", { + // status: "success", + // networkId, + // type: "multisig", + // }) + + return sendMessageToUi({ + type: "NEW_PENDING_MULTISIG_RES", + data: pendingMultisig, + }) + } catch (exception) { + const error = `${exception}` + + // TODO: Add tracking + + // analytics.track("createAccount", { + // status: "failure", + // networkId: networkId, + // type: "multisig", + // errorMessage: error, + // }) + + return sendMessageToUi({ + type: "NEW_PENDING_MULTISIG_REJ", + data: { error }, + }) + } + } + + case "ADD_MULTISIG_OWNERS": { + try { + const { address, signersToAdd, newThreshold } = msg.data + + const signersPayload = { + entrypoint: "addSigners", + calldata: stark.compileCalldata({ + new_threshold: newThreshold.toString(), + signers_to_add: signersToAdd.map((signer) => + utils.hexlify(utils.base58.decode(signer)), + ), + }), + contractAddress: address, + } + + await actionQueue.push({ + type: "TRANSACTION", + payload: { + transactions: signersPayload, + meta: { + title: "Add multisig owners", + type: "MULTISIG_ADD_SIGNERS", + }, + }, + }) + + return sendMessageToUi({ + type: "ADD_MULTISIG_OWNERS_RES", + }) + } catch (e) { + return sendMessageToUi({ + type: "ADD_MULTISIG_OWNERS_REJ", + data: { error: `${e}` }, + }) + } + } + + case "REMOVE_MULTISIG_OWNER": { + try { + const { address, signerToRemove, newThreshold } = msg.data + + const signersToRemove = [ + utils.hexlify(utils.base58.decode(signerToRemove)), + ] + + const signersPayload = { + entrypoint: "removeSigners", + calldata: stark.compileCalldata({ + new_threshold: newThreshold.toString(), + signers_to_remove: signersToRemove, + }), + contractAddress: address, + } + + await actionQueue.push({ + type: "TRANSACTION", + payload: { + transactions: signersPayload, + meta: { + title: "Remove multisig owner", + type: "MULTISIG_REMOVE_SIGNER", + }, + }, + }) + + return sendMessageToUi({ + type: "REMOVE_MULTISIG_OWNER_RES", + }) + } catch (e) { + return sendMessageToUi({ + type: "REMOVE_MULTISIG_OWNER_REJ", + data: { error: `${e}` }, + }) + } + } + case "UPDATE_MULTISIG_THRESHOLD": { + try { + const { address, newThreshold } = msg.data + + const thresholdPayload = { + entrypoint: "changeThreshold", + calldata: [newThreshold.toString()], + contractAddress: address, + } + + await actionQueue.push({ + type: "TRANSACTION", + payload: { + transactions: thresholdPayload, + meta: { + title: "Set confirmations threshold", + type: "MULTISIG_UPDATE_THRESHOLD", + }, + }, + }) + return sendMessageToUi({ + type: "UPDATE_MULTISIG_THRESHOLD_RES", + }) + } catch (e) { + return sendMessageToUi({ + type: "UPDATE_MULTISIG_THRESHOLD_REJ", + data: { error: `${e}` }, + }) + } + } + + case "ADD_MULTISIG_TRANSACTION_SIGNATURE": { + try { + const { requestId } = msg.data + + const selectedAccount = await wallet.getSelectedAccount() + + if (!selectedAccount) { + throw Error("No account selected") + } + + const multisigStarknetAccount = await wallet.getStarknetAccount( + selectedAccount, + ) + + if (!MultisigAccount.isMultisig(multisigStarknetAccount)) { + throw Error("Selected account is not a multisig account") + } + + const { transaction_hash } = + await multisigStarknetAccount.addRequestSignature(requestId) + + return sendMessageToUi({ + type: "ADD_MULTISIG_TRANSACTION_SIGNATURE_RES", + data: { + txHash: transaction_hash, + }, + }) + } catch (e) { + return sendMessageToUi({ + type: "ADD_MULTISIG_TRANSACTION_SIGNATURE_REJ", + data: { error: `${e}` }, + }) + } + } + } + + throw new UnhandledMessage() +} diff --git a/packages/extension/src/background/network/network.service.ts b/packages/extension/src/background/network/network.service.ts new file mode 100644 index 000000000..536b254f0 --- /dev/null +++ b/packages/extension/src/background/network/network.service.ts @@ -0,0 +1,31 @@ +import { uniqWith } from "lodash-es" + +import { Network, defaultNetworks } from "../../shared/network" +import { allNetworksStore, equalNetwork } from "../../shared/network/storage" +import { getNetworkStatuses } from "./networkStatus.worker" + +export interface NetworkService { + updateStatuses: () => Promise + loadNetworks: () => Promise +} + +export const networkService: NetworkService = { + async loadNetworks() { + const allNetworks = uniqWith( + [...(await allNetworksStore.get()), ...defaultNetworks], + equalNetwork, + ) + return allNetworks + }, + async updateStatuses() { + const networks = await this.loadNetworks() + const networkStatuses = await getNetworkStatuses(networks) + const networkWithUpdatedStatuses = networks.map((network) => { + return { + ...network, + status: networkStatuses[network.id] ?? "unknown", + } + }) + await allNetworksStore.push(networkWithUpdatedStatuses) + }, +} diff --git a/packages/extension/src/background/networkStatus.ts b/packages/extension/src/background/network/networkStatus.worker.ts similarity index 94% rename from packages/extension/src/background/networkStatus.ts rename to packages/extension/src/background/network/networkStatus.worker.ts index a7d3579bd..4ebd51888 100644 --- a/packages/extension/src/background/networkStatus.ts +++ b/packages/extension/src/background/network/networkStatus.worker.ts @@ -1,9 +1,9 @@ import urljoin from "url-join" -import { Network, NetworkStatus } from "../shared/network" -import { KeyValueStorage } from "../shared/storage" -import { createStaleWhileRevalidateCache } from "./swr" -import { fetchWithTimeout } from "./utils/fetchWithTimeout" +import { Network, NetworkStatus } from "../../shared/network" +import { KeyValueStorage } from "../../shared/storage" +import { createStaleWhileRevalidateCache } from "../swr" +import { fetchWithTimeout } from "../utils/fetchWithTimeout" type SwrCacheKey = string diff --git a/packages/extension/src/background/networkMessaging.ts b/packages/extension/src/background/networkMessaging.ts index 5f38ee3bb..7038aa16a 100644 --- a/packages/extension/src/background/networkMessaging.ts +++ b/packages/extension/src/background/networkMessaging.ts @@ -1,10 +1,9 @@ import { number, shortString } from "starknet" import { NetworkMessage } from "../shared/messages/NetworkMessage" -import { getNetwork, getNetworkByChainId, getNetworks } from "../shared/network" +import { getNetworkByChainId } from "../shared/network" import { UnhandledMessage } from "./background" import { HandleMessage } from "./background" -import { getNetworkStatuses } from "./networkStatus" export const handleNetworkMessage: HandleMessage = async ({ msg, @@ -12,62 +11,6 @@ export const handleNetworkMessage: HandleMessage = async ({ respond, }) => { switch (msg.type) { - case "GET_NETWORKS": { - return respond({ - type: "GET_NETWORKS_RES", - data: await getNetworks(), - }) - } - - case "GET_NETWORK": { - const allNetworks = await getNetworks() - - const network = allNetworks.find((n) => n.id === msg.data) - - if (!network) { - throw new Error(`Network with id ${msg.data} not found`) - } - - return respond({ - type: "GET_NETWORK_RES", - data: network, - }) - } - - case "GET_NETWORK_STATUSES": { - const networks = msg.data?.length ? msg.data : await getNetworks() - const statuses = await getNetworkStatuses(networks) - return respond({ - type: "GET_NETWORK_STATUSES_RES", - data: statuses, - }) - } - - case "REQUEST_ADD_CUSTOM_NETWORK": { - const exists = await getNetwork(msg.data.chainId) - - if (exists) { - return respond({ - type: "REQUEST_ADD_CUSTOM_NETWORK_REJ", - data: { - error: `Network with chainId ${msg.data.chainId} already exists`, - }, - }) - } - - const { meta } = await actionQueue.push({ - type: "REQUEST_ADD_CUSTOM_NETWORK", - payload: msg.data, - }) - - return respond({ - type: "REQUEST_ADD_CUSTOM_NETWORK_RES", - data: { - actionHash: meta.hash, - }, - }) - } - case "REQUEST_SWITCH_CUSTOM_NETWORK": { const { chainId } = msg.data diff --git a/packages/extension/src/background/nonce.ts b/packages/extension/src/background/nonce.ts index c51b3a216..8470c984d 100644 --- a/packages/extension/src/background/nonce.ts +++ b/packages/extension/src/background/nonce.ts @@ -1,7 +1,7 @@ import { number } from "starknet" import { KeyValueStorage } from "../shared/storage" -import { BaseWalletAccount } from "../shared/wallet.model" +import { BaseWalletAccount, WalletAccount } from "../shared/wallet.model" import { getAccountIdentifier } from "../shared/wallet.service" import { Wallet } from "./wallet" @@ -14,15 +14,20 @@ const nonceStore = new KeyValueStorage>( ) export async function getNonce( - baseWallet: BaseWalletAccount, + account: WalletAccount, wallet: Wallet, ): Promise { - const account = await wallet.getStarknetAccount(baseWallet) - const storageAddress = getAccountIdentifier(baseWallet) - const result = await account.getNonce() + const starknetAccount = await wallet.getStarknetAccount(account) + const storageAddress = getAccountIdentifier(account) + const result = await starknetAccount.getNonce() const nonceBn = number.toBN(result) const storedNonce = await nonceStore.get(storageAddress) + if (account.type === "multisig") { + // If the account is a multisig, we don't want to store the nonce + return number.toHex(nonceBn) + } + // If there's no nonce stored or the fetched nonce is bigger than the stored one, store the fetched nonce if (!storedNonce || nonceBn.gt(number.toBN(storedNonce))) { await nonceStore.set(storageAddress, number.toHex(nonceBn)) diff --git a/packages/extension/src/background/notification.ts b/packages/extension/src/background/notification.ts index 9863f6696..2347dab40 100644 --- a/packages/extension/src/background/notification.ts +++ b/packages/extension/src/background/notification.ts @@ -1,8 +1,10 @@ -import { Status } from "starknet" import browser from "webextension-polyfill" import { ArrayStorage } from "../shared/storage" -import { TransactionMeta } from "../shared/transactions" +import { + ExtendedTransactionStatus, + TransactionMeta, +} from "../shared/transactions" const notificationsStorage = new ArrayStorage( [], @@ -18,9 +20,9 @@ export async function addToAlreadyShown(hash: string) { await notificationsStorage.push(hash) } -export async function sentTransactionNotification( +export function sendTransactionNotification( hash: string, - status: Status, + status: ExtendedTransactionStatus, meta?: TransactionMeta, ) { const id = `TX:${hash}` @@ -37,3 +39,27 @@ export async function sentTransactionNotification( eventTime: Date.now(), }) } + +export function sendMultisigAccountReadyNotification(address: string) { + const id = `MS:READY:${address}` + const title = "Multisig is ready!" + return browser.notifications.create(id, { + type: "basic", + title, + message: "Your multisig account is ready to use", + iconUrl: "./assets/logo.png", + eventTime: Date.now(), + }) +} + +export function sendMultisigTransactionNotification(hash: string) { + const id = `MS:${hash}` + const title = "Multisig Transaction" + return browser.notifications.create(id, { + type: "basic", + title, + message: `New multisig transaction is waiting for your approval`, + iconUrl: "./assets/logo.png", + eventTime: Date.now(), + }) +} diff --git a/packages/extension/src/background/onboarding.ts b/packages/extension/src/background/onboarding.ts index 10ca1e573..b0ffee015 100644 --- a/packages/extension/src/background/onboarding.ts +++ b/packages/extension/src/background/onboarding.ts @@ -1,10 +1,8 @@ -import browser from "webextension-polyfill" +import { onboardingWorker } from "./__new/services/onboarding" -export const initOnboarding = () => { - browser.runtime.onInstalled.addListener((details) => { - if (details.reason === browser.runtime.OnInstalledReason.INSTALL) { - const url = browser.runtime.getURL("index.html") - browser.tabs.create({ url }) - } - }) +/** TODO: refactor: remove this facade */ +export function initOnboarding() { + return { + onboardingWorker, + } } diff --git a/packages/extension/src/background/recoveryMessaging.ts b/packages/extension/src/background/recoveryMessaging.ts deleted file mode 100644 index 2c6d627ad..000000000 --- a/packages/extension/src/background/recoveryMessaging.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { compactDecrypt } from "jose" -import { encode } from "starknet" - -import { getAccounts } from "../shared/account/store" -import { RecoveryMessage } from "../shared/messages/RecoveryMessage" -import { sendMessageToUi } from "./activeTabs" -import { UnhandledMessage } from "./background" -import { HandleMessage } from "./background" -import { downloadFile } from "./download" - -export const handleRecoveryMessage: HandleMessage = async ({ - msg, - messagingKeys: { privateKey }, - background: { wallet, transactionTracker }, -}) => { - switch (msg.type) { - case "RECOVER_BACKUP": { - try { - await wallet.importBackup(msg.data) - return sendMessageToUi({ type: "RECOVER_BACKUP_RES" }) - } catch (error) { - return sendMessageToUi({ - type: "RECOVER_BACKUP_REJ", - data: `${error}`, - }) - } - } - - case "DOWNLOAD_BACKUP_FILE": { - await downloadFile(await wallet.exportBackup()) - return sendMessageToUi({ type: "DOWNLOAD_BACKUP_FILE_RES" }) - } - - case "RECOVER_SEEDPHRASE": { - try { - const { secure, body } = msg.data - if (secure !== true) { - throw Error("session can only be started with encryption") - } - const { plaintext } = await compactDecrypt(body, privateKey) - const { - seedPhrase, - newPassword, - }: { - seedPhrase: string - newPassword: string - } = JSON.parse(encode.arrayBufferToString(plaintext)) - - await wallet.restoreSeedPhrase(seedPhrase, newPassword) - transactionTracker.loadHistory(await getAccounts()) - - return sendMessageToUi({ type: "RECOVER_SEEDPHRASE_RES" }) - } catch (error) { - return sendMessageToUi({ - type: "RECOVER_SEEDPHRASE_REJ", - data: "Something went wrong in the seedphrase recovery process", - }) - } - } - } - - throw new UnhandledMessage() -} diff --git a/packages/extension/src/background/sessionMessaging.ts b/packages/extension/src/background/sessionMessaging.ts index 519375aa6..9096a75e9 100644 --- a/packages/extension/src/background/sessionMessaging.ts +++ b/packages/extension/src/background/sessionMessaging.ts @@ -1,7 +1,7 @@ import { compactDecrypt } from "jose" -import { encode } from "starknet" import { SessionMessage } from "../shared/messages/SessionMessage" +import { bytesToUft8 } from "../shared/utils/encode" import { sendMessageToUi } from "./activeTabs" import { UnhandledMessage } from "./background" import { HandleMessage } from "./background" @@ -19,7 +19,7 @@ export const handleSessionMessage: HandleMessage = async ({ throw Error("session can only be started with encryption") } const { plaintext } = await compactDecrypt(body, privateKey) - const sessionPassword = encode.arrayBufferToString(plaintext) + const sessionPassword = bytesToUft8(plaintext) const result = await wallet.startSession(sessionPassword, (percent) => { respond({ type: "LOADING_PROGRESS", data: percent }) }) @@ -36,7 +36,7 @@ export const handleSessionMessage: HandleMessage = async ({ case "CHECK_PASSWORD": { const { body } = msg.data const { plaintext } = await compactDecrypt(body, privateKey) - const password = encode.arrayBufferToString(plaintext) + const password = bytesToUft8(plaintext) if (await wallet.checkPassword(password)) { return sendMessageToUi({ type: "CHECK_PASSWORD_RES" }) } diff --git a/packages/extension/src/background/shieldMessaging.ts b/packages/extension/src/background/shieldMessaging.ts index 0b1617d0b..79272226c 100644 --- a/packages/extension/src/background/shieldMessaging.ts +++ b/packages/extension/src/background/shieldMessaging.ts @@ -6,7 +6,7 @@ import { getNetworkSelector, withGuardianSelector, } from "../shared/account/selectors" -import { getAccounts } from "../shared/account/store" +import { accountService } from "../shared/account/service" import { ShieldMessage } from "../shared/messages/ShieldMessage" import { addBackendAccount, @@ -52,10 +52,10 @@ export const handleShieldMessage: HandleMessage = async ({ /** Get current account state */ - const localAccounts = await getAccounts( + const localAccounts = await accountService.get( getNetworkSelector(ARGENT_SHIELD_NETWORK_ID), ) - const localAccountsWithGuardian = await getAccounts( + const localAccountsWithGuardian = await accountService.get( withGuardianSelector, ) const backendAccounts = await getBackendAccounts() @@ -121,7 +121,7 @@ export const handleShieldMessage: HandleMessage = async ({ privateKeyHex, ) - const { r, s } = Signature.fromHex(deploySignature) + const { r, s } = Signature.fromDER(deploySignature.toDERHex()) const response = await addBackendAccount( publicKey, selectedAccount.address, diff --git a/packages/extension/src/background/transactions/badgeText.ts b/packages/extension/src/background/transactions/badgeText.ts index 2e7e923b4..a677743d1 100644 --- a/packages/extension/src/background/transactions/badgeText.ts +++ b/packages/extension/src/background/transactions/badgeText.ts @@ -4,10 +4,18 @@ import { hideNotificationBadge, showNotificationBadge, } from "../../shared/browser/badgeText" +import { + MultisigPendingTransaction, + multisigPendingTransactionsStore, +} from "../../shared/multisig/pendingTransactionsStore" +import { getMultisigAccountFromBaseWallet } from "../../shared/multisig/utils/baseMultisig" import { Transaction } from "../../shared/transactions" -import { BaseWalletAccount } from "../../shared/wallet.model" +import { + BaseWalletAccount, + MultisigWalletAccount, +} from "../../shared/wallet.model" import { accountsEqual } from "../../shared/wallet.service" -import { walletStore } from "../../shared/wallet/walletStore" +import { old_walletStore } from "../../shared/wallet/walletStore" import { transactionsStore } from "./store" // selects transactions that are pending and match the provided account @@ -19,25 +27,60 @@ export const pendingAccountTransactionsSelector = memoize( accountsEqual(account, transaction.account), ) +export const multisigPendingTransactionSelector = memoize( + (multisig: MultisigWalletAccount) => + (transaction: MultisigPendingTransaction) => { + const transactionAccount = { + address: transaction.address, + networkId: transaction.networkId, + } + + return accountsEqual(multisig, transactionAccount) && transaction.notify + }, +) + // show count of pending transactions for current account export const updateBadgeText = async () => { - const selectedWalletAccount = await walletStore.get("selected") + const selectedWalletAccount = await old_walletStore.get("selected") + if (!selectedWalletAccount) { hideNotificationBadge() return } - const selector = pendingAccountTransactionsSelector(selectedWalletAccount) - const pendingAccountTransactions = await transactionsStore.get(selector) - if (pendingAccountTransactions.length) { - showNotificationBadge(pendingAccountTransactions.length) + + const multisig = await getMultisigAccountFromBaseWallet(selectedWalletAccount) + + const transactionSelector = pendingAccountTransactionsSelector( + selectedWalletAccount, + ) + const pendingAccountTransactions = await transactionsStore.get( + transactionSelector, + ) + + let multisigTransactionsLength = 0 + + if (multisig) { + const multisigTransactionSelector = + multisigPendingTransactionSelector(multisig) + + multisigTransactionsLength = ( + await multisigPendingTransactionsStore.get(multisigTransactionSelector) + ).length + } + + const badgeSize = + pendingAccountTransactions.length + multisigTransactionsLength + + if (badgeSize) { + showNotificationBadge(badgeSize) } else { hideNotificationBadge() } } export const initBadgeText = () => { - walletStore.subscribe("selected", () => { + old_walletStore.subscribe("selected", () => { updateBadgeText() }) @@ -45,5 +88,9 @@ export const initBadgeText = () => { updateBadgeText() }) + multisigPendingTransactionsStore.subscribe(() => { + updateBadgeText() + }) + updateBadgeText() } diff --git a/packages/extension/src/background/transactions/fees/multisigFeeEstimation.ts b/packages/extension/src/background/transactions/fees/multisigFeeEstimation.ts new file mode 100644 index 000000000..20c796d94 --- /dev/null +++ b/packages/extension/src/background/transactions/fees/multisigFeeEstimation.ts @@ -0,0 +1,32 @@ +import { AllowArray, Call, number } from "starknet" +import { Account as Accountv5, ec } from "starknet5" + +import { getProviderv5 } from "../../../shared/network/provider" +import { WalletAccount } from "../../../shared/wallet.model" + +export const getEstimatedFeeForMultisigTx = async ( + selectedAccount: WalletAccount, + transactions: AllowArray, + nonce?: number.BigNumberish, +) => { + const providerV5 = getProviderv5(selectedAccount.network) + + const accountv5 = new Accountv5( + providerV5, + selectedAccount.address, + ec.starkCurve.utils.randomPrivateKey(), // Random private key works cuz we skipValidation is true + ) + + const { suggestedMaxFee, overall_fee } = await accountv5.estimateInvokeFee( + transactions, + { + nonce, + skipValidate: true, + }, + ) + + return { + overall_fee: number.toBN(overall_fee.toString()), + suggestedMaxFee: number.toBN(suggestedMaxFee.toString()), + } +} diff --git a/packages/extension/src/background/transactions/onupdate/index.ts b/packages/extension/src/background/transactions/onupdate/index.ts index cc848a0f3..03453ef72 100644 --- a/packages/extension/src/background/transactions/onupdate/index.ts +++ b/packages/extension/src/background/transactions/onupdate/index.ts @@ -1,6 +1,7 @@ import { handleChangeGuardianTransaction } from "./changeGuardian" import { handleDeclareContractTransaction } from "./declareContract" import { handleDeployAccountTransaction } from "./deployAccount" +import { handleMultisigUpdates } from "./multisigUpdates" import { checkResetStoredNonce } from "./nonce" import { notifyAboutCompletedTransactions } from "./notifications" import { TransactionUpdateListener } from "./type" @@ -11,6 +12,7 @@ const addedOrUpdatedHandlers: TransactionUpdateListener[] = [ handleDeployAccountTransaction, handleDeclareContractTransaction, handleChangeGuardianTransaction, + handleMultisigUpdates, checkResetStoredNonce, ] diff --git a/packages/extension/src/background/transactions/onupdate/multisigUpdates.ts b/packages/extension/src/background/transactions/onupdate/multisigUpdates.ts new file mode 100644 index 000000000..554ad1333 --- /dev/null +++ b/packages/extension/src/background/transactions/onupdate/multisigUpdates.ts @@ -0,0 +1,15 @@ +import { updateMultisigAccountDetails } from "../../../shared/account/update" +import { MULTISG_TXN_TYPES, Transaction } from "../../../shared/transactions" +import { TransactionUpdateListener } from "./type" + +export const handleMultisigUpdates: TransactionUpdateListener = async ( + updates: Transaction[], +) => { + const multisigUpdates = updates.filter( + (t) => t.meta?.type && MULTISG_TXN_TYPES.includes(t.meta.type), + ) + + if (multisigUpdates.length > 0) { + await updateMultisigAccountDetails(multisigUpdates.map((t) => t.account)) + } +} diff --git a/packages/extension/src/background/transactions/onupdate/notifications.ts b/packages/extension/src/background/transactions/onupdate/notifications.ts index bb8e34085..1a48fc33e 100644 --- a/packages/extension/src/background/transactions/onupdate/notifications.ts +++ b/packages/extension/src/background/transactions/onupdate/notifications.ts @@ -1,9 +1,9 @@ -import { SUCCESS_STATUSES } from "../../../shared/transactions" +import { FAILED_STATUS, SUCCESS_STATUSES } from "../../../shared/transactions" import { decrementTransactionsBeforeReview } from "../../../shared/userReview" import { addToAlreadyShown, hasShownNotification, - sentTransactionNotification, + sendTransactionNotification, } from "../../notification" import { TransactionUpdateListener } from "./type" @@ -12,14 +12,14 @@ export const notifyAboutCompletedTransactions: TransactionUpdateListener = for (const transaction of transactions) { const { hash, status, meta, account } = transaction if ( - (SUCCESS_STATUSES.includes(status) || status === "REJECTED") && + (SUCCESS_STATUSES.includes(status) || FAILED_STATUS.includes(status)) && !(await hasShownNotification(hash)) ) { addToAlreadyShown(hash) if (!account.hidden && !meta?.isDeployAccount) { await decrementTransactionsBeforeReview() - sentTransactionNotification(hash, status, meta) + sendTransactionNotification(hash, status, meta) } } } diff --git a/packages/extension/src/background/transactions/onupdate/type.ts b/packages/extension/src/background/transactions/onupdate/type.ts index b7e404f94..4c4b7e7d1 100644 --- a/packages/extension/src/background/transactions/onupdate/type.ts +++ b/packages/extension/src/background/transactions/onupdate/type.ts @@ -1,3 +1,5 @@ import type { Transaction } from "../../../shared/transactions" -export type TransactionUpdateListener = (updates: Transaction[]) => void +export type TransactionUpdateListener = ( + updates: Transaction[], +) => void | Promise diff --git a/packages/extension/src/background/transactions/store.ts b/packages/extension/src/background/transactions/store.ts index daf0e0d64..3d93c9bd2 100644 --- a/packages/extension/src/background/transactions/store.ts +++ b/packages/extension/src/background/transactions/store.ts @@ -3,6 +3,7 @@ import { differenceWith } from "lodash-es" import { ArrayStorage } from "../../shared/storage" import { StorageChange } from "../../shared/storage/types" import { + ExtendedTransactionStatus, Transaction, TransactionRequest, compareTransactions, @@ -18,14 +19,19 @@ export const transactionsStore = new ArrayStorage([], { const timestampInSeconds = (): number => Math.floor(Date.now() / 1000) -export const addTransaction = async (transaction: TransactionRequest) => { +export const addTransaction = async ( + transaction: TransactionRequest, + status?: ExtendedTransactionStatus, +) => { // sanity checks if (!checkTransactionHash(transaction.hash)) { return // dont throw } + const defaultStatus: ExtendedTransactionStatus = "RECEIVED" + const newTransaction = { - status: "RECEIVED" as const, + status: status ?? defaultStatus, timestamp: timestampInSeconds(), ...transaction, } diff --git a/packages/extension/src/background/transactions/transactionExecution.ts b/packages/extension/src/background/transactions/transactionExecution.ts index e5f57e746..1592e46b2 100644 --- a/packages/extension/src/background/transactions/transactionExecution.ts +++ b/packages/extension/src/background/transactions/transactionExecution.ts @@ -1,5 +1,6 @@ import { BigNumber } from "ethers" import { + Account, Call, EstimateFee, TransactionBulk, @@ -22,18 +23,20 @@ import { analytics } from "../analytics" import { BackgroundService } from "../background" import { getNonce, increaseStoredNonce, resetStoredNonce } from "../nonce" import { argentMaxFee } from "../utils/argentMaxFee" +import { getEstimatedFeeForMultisigTx } from "./fees/multisigFeeEstimation" import { getEstimatedFees } from "./fees/store" import { addTransaction, transactionsStore } from "./store" export const checkTransactionHash = ( transactionHash?: number.BigNumberish, + account?: WalletAccount, ): boolean => { try { if (!transactionHash) { throw Error("transactionHash not defined") } const bn = number.toBN(transactionHash) - if (bn.lte(constants.ZERO)) { + if (bn.lte(constants.ZERO) && account?.type !== "multisig") { throw Error("transactionHash needs to be >0") } return true @@ -99,10 +102,15 @@ export const executeTransactionAction = async ( !(await isAccountDeployed(selectedAccount, starknetAccount.getClassAt)) ) { if ("estimateFeeBulk" in starknetAccount) { + const deployAccountPayload = + selectedAccount.type === "multisig" + ? await wallet.getMultisigDeploymentPayload(selectedAccount) + : await wallet.getAccountDeploymentPayload(selectedAccount) + const bulkTransactions: TransactionBulk = [ { type: "DEPLOY_ACCOUNT", - payload: await wallet.getAccountDeploymentPayload(selectedAccount), + payload: deployAccountPayload, }, { type: "INVOKE_FUNCTION", @@ -123,7 +131,6 @@ export const executeTransactionAction = async ( const { account, txHash } = await wallet.deployAccount(selectedAccount, { maxFee: maxADFee, }) - if (!checkTransactionHash(txHash)) { throw Error( "Deploy Account Transaction could not get added to the sequencer", @@ -146,6 +153,14 @@ export const executeTransactionAction = async ( type: "DEPLOY_ACCOUNT", }, }) + } else if (selectedAccount.type === "multisig") { + const { suggestedMaxFee } = await getEstimatedFeeForMultisigTx( + selectedAccount, + transactions, + nonce, + ) + + maxFee = argentMaxFee(suggestedMaxFee) } else { if (hasUpgradePending && !preComputedFees?.suggestedMaxFee) { const oldStarknetAccount = await wallet.getStarknetAccount( @@ -167,30 +182,39 @@ export const executeTransactionAction = async ( } } - const transaction = await starknetAccount.execute(transactions, abis, { + const acc = + selectedAccount.type === "multisig" && starknetAccount instanceof Account // Multisig uses latest account interface + ? wallet.getStarknetAccountOfType(starknetAccount, "multisig") + : starknetAccount + const transaction = await acc.execute(transactions, abis, { ...transactionsDetail, nonce, maxFee, }) - if (!checkTransactionHash(transaction.transaction_hash)) { + if (!checkTransactionHash(transaction.transaction_hash, selectedAccount)) { throw Error("Transaction could not get added to the sequencer") } const title = nameTransaction(transactions) - await addTransaction({ - hash: transaction.transaction_hash, - account: selectedAccount, - meta: { - ...meta, - title, - transactions, - type: "DEPLOY_ACCOUNT", - }, - }) + // TODO: Remove this conditional as we now fallback to computed transactionHash for multisig + // So we can always add the transaction to the queue. The added transaction will have + // status "NOT_RECEIVED" until all the owners have signed the transaction + if (selectedAccount.type !== "multisig") { + await addTransaction({ + hash: transaction.transaction_hash, + account: selectedAccount, + meta: { + ...meta, + title, + transactions, + type: "INVOKE_FUNCTION", + }, + }) + } - if (!nonceWasProvidedByUI) { + if (!nonceWasProvidedByUI && selectedAccount.type !== "multisig") { await increaseStoredNonce(selectedAccount) } diff --git a/packages/extension/src/background/transactions/transactionMessaging.ts b/packages/extension/src/background/transactions/transactionMessaging.ts index a805d5ebf..2d029fada 100644 --- a/packages/extension/src/background/transactions/transactionMessaging.ts +++ b/packages/extension/src/background/transactions/transactionMessaging.ts @@ -11,413 +11,355 @@ import { TransactionMessage } from "../../shared/messages/TransactionMessage" import { isAccountDeployed } from "../accountDeploy" import { HandleMessage, UnhandledMessage } from "../background" import { argentMaxFee } from "../utils/argentMaxFee" +import { getEstimatedFeeForMultisigTx } from "./fees/multisigFeeEstimation" import { addEstimatedFees } from "./fees/store" -export const handleTransactionMessage: HandleMessage = - async ({ msg, background: { wallet, actionQueue }, respond: respond }) => { - switch (msg.type) { - case "EXECUTE_TRANSACTION": { - const { meta } = await actionQueue.push({ - type: "TRANSACTION", - payload: msg.data, - }) - return respond({ - type: "EXECUTE_TRANSACTION_RES", - data: { actionHash: meta.hash }, - }) - } +export const handleTransactionMessage: HandleMessage< + TransactionMessage +> = async ({ msg, background: { wallet, actionQueue }, respond: respond }) => { + switch (msg.type) { + case "EXECUTE_TRANSACTION": { + const { meta } = await actionQueue.push({ + type: "TRANSACTION", + payload: msg.data, + }) + return respond({ + type: "EXECUTE_TRANSACTION_RES", + data: { actionHash: meta.hash }, + }) + } - case "ESTIMATE_TRANSACTION_FEE": { - const selectedAccount = await wallet.getSelectedAccount() - const starknetAccount = await wallet.getSelectedStarknetAccount() - const transactions = msg.data + case "ESTIMATE_TRANSACTION_FEE": { + const selectedAccount = await wallet.getSelectedAccount() + const starknetAccount = await wallet.getSelectedStarknetAccount() + const transactions = msg.data - if (!selectedAccount) { - throw Error("no accounts") - } - try { - let txFee = "0", - maxTxFee = "0", - accountDeploymentFee: string | undefined, - maxADFee: string | undefined - - if ( - selectedAccount.needsDeploy && - !(await isAccountDeployed( - selectedAccount, - starknetAccount.getClassAt, - )) - ) { - if ("estimateFeeBulk" in starknetAccount) { - const bulkTransactions: TransactionBulk = [ - { - type: "DEPLOY_ACCOUNT", - payload: await wallet.getAccountDeploymentPayload( - selectedAccount, - ), - }, - { - type: "INVOKE_FUNCTION", - payload: transactions, - }, - ] + if (!selectedAccount) { + throw Error("no accounts") + } + try { + let txFee = "0", + maxTxFee = "0", + accountDeploymentFee: string | undefined, + maxADFee: string | undefined - const estimateFeeBulk = await starknetAccount.estimateFeeBulk( - bulkTransactions, - ) + if ( + selectedAccount.needsDeploy && + !(await isAccountDeployed( + selectedAccount, + starknetAccount.getClassAt, + )) + ) { + if ("estimateFeeBulk" in starknetAccount) { + const bulkTransactions: TransactionBulk = [ + { + type: "DEPLOY_ACCOUNT", + payload: await wallet.getAccountDeploymentPayload( + selectedAccount, + ), + }, + { + type: "INVOKE_FUNCTION", + payload: transactions, + }, + ] + + const estimateFeeBulk = await starknetAccount.estimateFeeBulk( + bulkTransactions, + ) + + accountDeploymentFee = number.toHex(estimateFeeBulk[0].overall_fee) + txFee = number.toHex(estimateFeeBulk[1].overall_fee) + + maxADFee = argentMaxFee(estimateFeeBulk[0].suggestedMaxFee) + maxTxFee = argentMaxFee(estimateFeeBulk[1].suggestedMaxFee) + } + } else if (selectedAccount.type === "multisig") { + const { overall_fee, suggestedMaxFee } = + await getEstimatedFeeForMultisigTx(selectedAccount, transactions) + txFee = number.toHex(overall_fee) + maxTxFee = number.toHex(suggestedMaxFee) + } else { + const { overall_fee, suggestedMaxFee } = + await starknetAccount.estimateFee(transactions) - accountDeploymentFee = number.toHex( - estimateFeeBulk[0].overall_fee, - ) - txFee = number.toHex(estimateFeeBulk[1].overall_fee) + txFee = number.toHex(overall_fee) + maxTxFee = number.toHex(suggestedMaxFee) // Here, maxFee = estimatedFee * 1.5x + } - maxADFee = argentMaxFee(estimateFeeBulk[0].suggestedMaxFee) - maxTxFee = argentMaxFee(estimateFeeBulk[1].suggestedMaxFee) - } - } else { - const { overall_fee, suggestedMaxFee } = - await starknetAccount.estimateFee(transactions) + const suggestedMaxFee = argentMaxFee(maxTxFee) - txFee = number.toHex(overall_fee) - maxTxFee = number.toHex(suggestedMaxFee) // Here, maxFee = estimatedFee * 1.5x - } - - const suggestedMaxFee = number.toHex( - stark.estimatedFeeToMaxFee(maxTxFee, 1), // This adds the 3x overhead. i.e: suggestedMaxFee = maxFee * 2x = estimatedFee * 3x - ) - addEstimatedFees({ + addEstimatedFees({ + amount: txFee, + suggestedMaxFee, + accountDeploymentFee, + maxADFee, + transactions, + }) + return respond({ + type: "ESTIMATE_TRANSACTION_FEE_RES", + data: { amount: txFee, suggestedMaxFee, accountDeploymentFee, maxADFee, - transactions, - }) - return respond({ - type: "ESTIMATE_TRANSACTION_FEE_RES", - data: { - amount: txFee, - suggestedMaxFee, - accountDeploymentFee, - maxADFee, - }, - }) - } catch (error) { - console.error(error) - return respond({ - type: "ESTIMATE_TRANSACTION_FEE_REJ", - data: { - error: - (error as any)?.message?.toString?.() ?? - (error as any)?.toString?.() ?? - "Unkown error", - }, - }) - } + }, + }) + } catch (error) { + console.error(error) + return respond({ + type: "ESTIMATE_TRANSACTION_FEE_REJ", + data: { + error: + (error as any)?.message?.toString?.() ?? + (error as any)?.toString?.() ?? + "Unkown error", + }, + }) } + } - case "ESTIMATE_ACCOUNT_DEPLOYMENT_FEE": { - const providedAccount = msg.data - const account = providedAccount - ? await wallet.getAccount(providedAccount) - : await wallet.getSelectedAccount() + case "ESTIMATE_ACCOUNT_DEPLOYMENT_FEE": { + const providedAccount = msg.data + const account = providedAccount + ? await wallet.getAccount(providedAccount) + : await wallet.getSelectedAccount() - if (!account) { - throw Error("no accounts") - } + if (!account) { + throw Error("no accounts") + } - try { - const { overall_fee, suggestedMaxFee } = - await wallet.getAccountDeploymentFee(account) + try { + const { overall_fee, suggestedMaxFee } = + await wallet.getAccountDeploymentFee(account) - const maxADFee = number.toHex( - stark.estimatedFeeToMaxFee(suggestedMaxFee, 1), // This adds the 3x overhead. i.e: suggestedMaxFee = maxFee * 2x = estimatedFee * 3x - ) + const maxADFee = number.toHex( + stark.estimatedFeeToMaxFee(suggestedMaxFee, 1), // This adds the 3x overhead. i.e: suggestedMaxFee = maxFee * 2x = estimatedFee * 3x + ) + return respond({ + type: "ESTIMATE_ACCOUNT_DEPLOYMENT_FEE_RES", + data: { + amount: number.toHex(overall_fee), + maxADFee, + }, + }) + } catch (error) { + // FIXME: This is a temporary fix for the case where the user has a multisig account. + // Once starknet 0.11 is released, we can remove this. + if (account.type === "multisig") { + const fallbackPrice = number.toBN(10e14) return respond({ type: "ESTIMATE_ACCOUNT_DEPLOYMENT_FEE_RES", data: { - amount: number.toHex(overall_fee), - maxADFee, - }, - }) - } catch (error) { - console.error(error) - return respond({ - type: "ESTIMATE_ACCOUNT_DEPLOYMENT_FEE_REJ", - data: { - error: - (error as any)?.message?.toString?.() ?? - (error as any)?.toString?.() ?? - "Unkown error", + amount: number.toHex(fallbackPrice), + maxADFee: argentMaxFee(fallbackPrice), }, }) } + + console.error(error) + return respond({ + type: "ESTIMATE_ACCOUNT_DEPLOYMENT_FEE_REJ", + data: { + error: + (error as any)?.message?.toString?.() ?? + (error as any)?.toString?.() ?? + "Unkown error", + }, + }) } + } - case "ESTIMATE_DECLARE_CONTRACT_FEE": { - const { classHash, contract, ...restData } = msg.data + case "ESTIMATE_DECLARE_CONTRACT_FEE": { + const { classHash, contract, ...restData } = msg.data - const selectedAccount = await wallet.getSelectedAccount() - const selectedStarknetAccount = - "address" in restData - ? await wallet.getStarknetAccount(restData) - : await wallet.getSelectedStarknetAccount() + const selectedAccount = await wallet.getSelectedAccount() + const selectedStarknetAccount = + "address" in restData + ? await wallet.getStarknetAccount(restData) + : await wallet.getSelectedStarknetAccount() - if (!selectedStarknetAccount) { - throw Error("no accounts") - } - - let txFee = "0", - maxTxFee = "0", - accountDeploymentFee: string | undefined, - maxADFee: string | undefined + if (!selectedStarknetAccount) { + throw Error("no accounts") + } - try { - if ( - selectedAccount?.needsDeploy && - !(await isAccountDeployed( - selectedAccount, - selectedStarknetAccount.getClassAt, - )) - ) { - if ("estimateFeeBulk" in selectedStarknetAccount) { - const bulkTransactions: TransactionBulk = [ - { - type: "DEPLOY_ACCOUNT", - payload: await wallet.getAccountDeploymentPayload( - selectedAccount, - ), - }, - { - type: "DECLARE", - payload: { - classHash, - contract, - }, + let txFee = "0", + maxTxFee = "0", + accountDeploymentFee: string | undefined, + maxADFee: string | undefined + + try { + if ( + selectedAccount?.needsDeploy && + !(await isAccountDeployed( + selectedAccount, + selectedStarknetAccount.getClassAt, + )) + ) { + if ("estimateFeeBulk" in selectedStarknetAccount) { + const deployPayload = + selectedAccount.type === "multisig" + ? await wallet.getMultisigDeploymentPayload(selectedAccount) + : await wallet.getAccountDeploymentPayload(selectedAccount) + const bulkTransactions: TransactionBulk = [ + { + type: "DEPLOY_ACCOUNT", + payload: deployPayload, + }, + { + type: "DECLARE", + payload: { + classHash, + contract, }, - ] + }, + ] - const estimateFeeBulk = - await selectedStarknetAccount.estimateFeeBulk(bulkTransactions) + const estimateFeeBulk = + await selectedStarknetAccount.estimateFeeBulk(bulkTransactions) - accountDeploymentFee = number.toHex( - estimateFeeBulk[0].overall_fee, - ) - txFee = number.toHex(estimateFeeBulk[1].overall_fee) + accountDeploymentFee = number.toHex(estimateFeeBulk[0].overall_fee) + txFee = number.toHex(estimateFeeBulk[1].overall_fee) - maxADFee = argentMaxFee(estimateFeeBulk[0].suggestedMaxFee) - maxTxFee = estimateFeeBulk[1].suggestedMaxFee - } + maxADFee = argentMaxFee(estimateFeeBulk[0].suggestedMaxFee) + maxTxFee = estimateFeeBulk[1].suggestedMaxFee + } + } else { + if ("estimateDeclareFee" in selectedStarknetAccount) { + const { overall_fee, suggestedMaxFee } = + await selectedStarknetAccount.estimateDeclareFee({ + classHash, + contract, + }) + txFee = number.toHex(overall_fee) + maxTxFee = number.toHex(suggestedMaxFee) } else { - if ("estimateDeclareFee" in selectedStarknetAccount) { - const { overall_fee, suggestedMaxFee } = - await selectedStarknetAccount.estimateDeclareFee({ - classHash, - contract, - }) - txFee = number.toHex(overall_fee) - maxTxFee = number.toHex(suggestedMaxFee) - } else { - throw Error("estimateDeclareFee not supported") - } + throw Error("estimateDeclareFee not supported") } - - const suggestedMaxFee = argentMaxFee(maxTxFee) // This add the 3x overhead. i.e: suggestedMaxFee = maxFee * 2x = estimatedFee * 3x - - return respond({ - type: "ESTIMATE_DECLARE_CONTRACT_FEE_RES", - data: { - amount: txFee, - suggestedMaxFee, - accountDeploymentFee, - maxADFee, - }, - }) - } 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 { classHash, constructorCalldata, salt, unique } = msg.data + const suggestedMaxFee = argentMaxFee(maxTxFee) // This add the 3x overhead. i.e: suggestedMaxFee = maxFee * 2x = estimatedFee * 3x - const selectedAccount = await wallet.getSelectedAccount() - const selectedStarknetAccount = - await wallet.getSelectedStarknetAccount() - - if (!selectedStarknetAccount || !selectedAccount) { - throw Error("no accounts") - } - - let txFee = "0", - maxTxFee = "0", - accountDeploymentFee: string | undefined, - maxADFee: string | undefined + return respond({ + type: "ESTIMATE_DECLARE_CONTRACT_FEE_RES", + data: { + amount: txFee, + suggestedMaxFee, + accountDeploymentFee, + maxADFee, + }, + }) + } 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", + }, + }) + } + } - try { - if ( - selectedAccount?.needsDeploy && - !(await isAccountDeployed( - selectedAccount, - selectedStarknetAccount.getClassAt, - )) - ) { - if ("estimateFeeBulk" in selectedStarknetAccount) { - const bulkTransactions: TransactionBulk = [ - { - type: "DEPLOY_ACCOUNT", - payload: await wallet.getAccountDeploymentPayload( - selectedAccount, - ), - }, - { - type: "DEPLOY", - payload: { - classHash, - salt, - unique, - constructorCalldata, - }, - }, - ] + case "ESTIMATE_DEPLOY_CONTRACT_FEE": { + const { classHash, constructorCalldata, salt, unique } = msg.data - const estimateFeeBulk = - await selectedStarknetAccount.estimateFeeBulk(bulkTransactions) + const selectedAccount = await wallet.getSelectedAccount() + const selectedStarknetAccount = await wallet.getSelectedStarknetAccount() - accountDeploymentFee = number.toHex( - estimateFeeBulk[0].overall_fee, - ) - txFee = number.toHex(estimateFeeBulk[1].overall_fee) + if (!selectedStarknetAccount || !selectedAccount) { + throw Error("no accounts") + } - maxADFee = argentMaxFee(estimateFeeBulk[0].suggestedMaxFee) - maxTxFee = estimateFeeBulk[1].suggestedMaxFee - } - } else { - if ("estimateDeployFee" in selectedStarknetAccount) { - const { overall_fee, suggestedMaxFee } = - await selectedStarknetAccount.estimateDeployFee({ + let txFee = "0", + maxTxFee = "0", + accountDeploymentFee: string | undefined, + maxADFee: string | undefined + + try { + if ( + selectedAccount?.needsDeploy && + !(await isAccountDeployed( + selectedAccount, + selectedStarknetAccount.getClassAt, + )) + ) { + if ("estimateFeeBulk" in selectedStarknetAccount) { + const bulkTransactions: TransactionBulk = [ + { + type: "DEPLOY_ACCOUNT", + payload: await wallet.getAccountDeploymentPayload( + selectedAccount, + ), + }, + { + type: "DEPLOY", + payload: { classHash, salt, unique, constructorCalldata, - }) - txFee = number.toHex(overall_fee) - maxTxFee = number.toHex(suggestedMaxFee) - } else { - throw Error("estimateDeployFee not supported") - } - } - - const suggestedMaxFee = argentMaxFee(maxTxFee) // This adds the 3x overhead. i.e: suggestedMaxFee = maxFee * 2x = estimatedFee * 3x - - return respond({ - type: "ESTIMATE_DEPLOY_CONTRACT_FEE_RES", - data: { - amount: txFee, - suggestedMaxFee, - accountDeploymentFee, - maxADFee, - }, - }) - } 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] + const estimateFeeBulk = + await selectedStarknetAccount.estimateFeeBulk(bulkTransactions) - try { - const selectedAccount = await wallet.getSelectedAccount() - const starknetAccount = - (await wallet.getSelectedStarknetAccount()) as Account // Old accounts are not supported + accountDeploymentFee = number.toHex(estimateFeeBulk[0].overall_fee) + txFee = number.toHex(estimateFeeBulk[1].overall_fee) - if (!selectedAccount) { - throw Error("no accounts") + maxADFee = argentMaxFee(estimateFeeBulk[0].suggestedMaxFee) + maxTxFee = estimateFeeBulk[1].suggestedMaxFee } - - const nonce = await starknetAccount.getNonce() - - const chainId = starknetAccount.chainId - - const version = number.toHex(hash.feeTransactionVersion) - - const signerDetails: InvocationsSignerDetails = { - walletAddress: starknetAccount.address, - nonce, - maxFee: 0, - version, - chainId, + } else { + if ("estimateDeployFee" in selectedStarknetAccount) { + const { overall_fee, suggestedMaxFee } = + await selectedStarknetAccount.estimateDeployFee({ + classHash, + salt, + unique, + constructorCalldata, + }) + txFee = number.toHex(overall_fee) + maxTxFee = number.toHex(suggestedMaxFee) + } else { + throw Error("estimateDeployFee not supported") } + } - // TODO: Use this when Simulate Transaction allows multiple transaction types - // const signerDetailsWithZeroNonce = { - // ...signerDetails, - // nonce: 0, - // } - - // const accountDeployPayload = await wallet.getAccountDeploymentPayload( - // selectedAccount, - // ) - - // const accountDeployInvocation = - // await starknetAccount.buildAccountDeployPayload( - // accountDeployPayload, - // signerDetailsWithZeroNonce, - // ) - - const { contractAddress, calldata, signature } = - await starknetAccount.buildInvocation(transactions, signerDetails) - - const invocation = { - type: "INVOKE_FUNCTION" as const, - contract_address: contractAddress, - calldata, - signature, - nonce, - version, - } + const suggestedMaxFee = argentMaxFee(maxTxFee) // This adds the 3x overhead. i.e: suggestedMaxFee = maxFee * 2x = estimatedFee * 3x - return respond({ - type: "SIMULATE_TRANSACTION_INVOCATION_RES", - data: { - invocation, - chainId, - }, - }) - } catch (error) { - console.log(error) - return respond({ - type: "SIMULATE_TRANSACTION_INVOCATION_REJ", - data: { - error: - (error as any)?.message?.toString() ?? - (error as any)?.toString() ?? - "Unkown error", - }, - }) - } + return respond({ + type: "ESTIMATE_DEPLOY_CONTRACT_FEE_RES", + data: { + amount: txFee, + suggestedMaxFee, + accountDeploymentFee, + maxADFee, + }, + }) + } 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] - case "SIMULATE_TRANSACTION_FALLBACK": { + try { const selectedAccount = await wallet.getSelectedAccount() const starknetAccount = (await wallet.getSelectedStarknetAccount()) as Account // Old accounts are not supported @@ -428,35 +370,103 @@ export const handleTransactionMessage: HandleMessage = const nonce = await starknetAccount.getNonce() - try { - const simulated = await starknetAccount.simulateTransaction( - msg.data, - { - nonce, - }, - ) + const chainId = starknetAccount.chainId - return respond({ - type: "SIMULATE_TRANSACTION_FALLBACK_RES", - data: simulated, - }) - } catch (error) { - return respond({ - type: "SIMULATE_TRANSACTION_FALLBACK_REJ", - data: { - error: - (error as any)?.message?.toString() ?? - (error as any)?.toString() ?? - "Unkown error", - }, - }) + const version = number.toHex(hash.feeTransactionVersion) + + const signerDetails: InvocationsSignerDetails = { + walletAddress: starknetAccount.address, + nonce, + maxFee: 0, + version, + chainId, + } + + // TODO: Use this when Simulate Transaction allows multiple transaction types + // const signerDetailsWithZeroNonce = { + // ...signerDetails, + // nonce: 0, + // } + + // const accountDeployPayload = await wallet.getAccountDeploymentPayload( + // selectedAccount, + // ) + + // const accountDeployInvocation = + // await starknetAccount.buildAccountDeployPayload( + // accountDeployPayload, + // signerDetailsWithZeroNonce, + // ) + + const { contractAddress, calldata, signature } = + await starknetAccount.buildInvocation(transactions, signerDetails) + + const invocation = { + type: "INVOKE_FUNCTION" as const, + contract_address: contractAddress, + calldata, + signature, + nonce, + version, } - } - case "TRANSACTION_FAILED": { - return await actionQueue.remove(msg.data.actionHash) + return respond({ + type: "SIMULATE_TRANSACTION_INVOCATION_RES", + data: { + invocation, + chainId, + }, + }) + } catch (error) { + console.log(error) + return respond({ + type: "SIMULATE_TRANSACTION_INVOCATION_REJ", + data: { + error: + (error as any)?.message?.toString() ?? + (error as any)?.toString() ?? + "Unkown error", + }, + }) } } - throw new UnhandledMessage() + case "TRANSACTION_FAILED": { + return await actionQueue.remove(msg.data.actionHash) + } + + case "SIMULATE_TRANSACTION_FALLBACK": { + const selectedAccount = await wallet.getSelectedAccount() + const starknetAccount = + (await wallet.getSelectedStarknetAccount()) as Account // Old accounts are not supported + + if (!selectedAccount) { + throw Error("no accounts") + } + + const nonce = await starknetAccount.getNonce() + + try { + const simulated = await starknetAccount.simulateTransaction(msg.data, { + nonce, + }) + + return respond({ + type: "SIMULATE_TRANSACTION_FALLBACK_RES", + data: simulated, + }) + } catch (error) { + return respond({ + type: "SIMULATE_TRANSACTION_FALLBACK_REJ", + data: { + error: + (error as any)?.message?.toString() ?? + (error as any)?.toString() ?? + "Unkown error", + }, + }) + } + } } + throw new UnhandledMessage() +} diff --git a/packages/extension/src/background/udcMessaging.ts b/packages/extension/src/background/udcMessaging.ts index 505b31496..f80b724b5 100644 --- a/packages/extension/src/background/udcMessaging.ts +++ b/packages/extension/src/background/udcMessaging.ts @@ -5,13 +5,10 @@ import { HandleMessage, UnhandledMessage } from "./background" export const handleUdcMessaging: HandleMessage = async ({ msg, - background, + background: { actionQueue, wallet }, respond, }) => { - const { actionQueue, wallet } = background - const { type } = msg - - switch (type) { + switch (msg.type) { case "REQUEST_DECLARE_CONTRACT": { const { data } = msg const { classHash, contract, ...restData } = data diff --git a/packages/extension/src/background/wallet.ts b/packages/extension/src/background/wallet.ts index fcad33993..18b9dff23 100644 --- a/packages/extension/src/background/wallet.ts +++ b/packages/extension/src/background/wallet.ts @@ -1,8 +1,17 @@ -import { ethers } from "ethers" +import { ethers, utils } from "ethers" import { ProgressCallback } from "ethers/lib/utils" -import { find, memoize, noop, throttle, union } from "lodash-es" +import { + find, + isEmpty, + memoize, + noop, + partition, + throttle, + union, +} from "lodash-es" import { Account, + DeployAccountContractPayload, DeployAccountContractTransaction, EstimateFee, InvocationsDetails, @@ -16,7 +25,16 @@ import { import { Account as Accountv4 } from "starknet4" import browser from "webextension-polyfill" -import { ArgentAccountType } from "./../shared/wallet.model" +import { updateAccountsWithNames } from "./../shared/account/details/updateAccountsWithNames" +import { sortByDerivationPath } from "./../shared/utils/accountsMultisigSort" +import { + ArgentAccountType, + BaseMultisigWalletAccount, + CreateAccountType, + CreateWalletAccount, + MultisigData, + MultisigWalletAccount, +} from "./../shared/wallet.model" import { getAccountEscapeFromChain } from "../shared/account/details/getAccountEscapeFromChain" import { getAccountGuardiansFromChain } from "../shared/account/details/getAccountGuardiansFromChain" import { getAccountTypesFromChain } from "../shared/account/details/getAccountTypesFromChain" @@ -26,6 +44,11 @@ import { } from "../shared/account/details/getAndMergeAccountDetails" import { withHiddenSelector } from "../shared/account/selectors" import { getMulticallForNetwork } from "../shared/multicall" +import { MultisigAccount } from "../shared/multisig/account" +import { fetchMultisigDataForSigner } from "../shared/multisig/multisig.service" +import { MultisigSigner } from "../shared/multisig/signer" +import { PendingMultisig } from "../shared/multisig/types" +import { getMultisigAccountFromBaseWallet } from "../shared/multisig/utils/baseMultisig" import { Network, defaultNetwork, @@ -33,7 +56,6 @@ import { getProvider, } from "../shared/network" import { getProviderv4 } from "../shared/network/provider" -import { mapArgentAccountTypeToImplementationKey } from "../shared/network/utils" import { cosignerSign } from "../shared/shield/backend/account" import { ARGENT_SHIELD_ENABLED } from "../shared/shield/constants" import { GuardianSelfSigner } from "../shared/shield/GuardianSelfSigner" @@ -96,11 +118,6 @@ export interface WalletStorageProps { selected?: BaseWalletAccount | null discoveredOnce?: boolean } -/* -export const walletStore = new KeyValueStorage( - {}, - "core:wallet", -) */ export const sessionStore = new ObjectStorage(null, { namespace: "core:wallet:session", @@ -132,6 +149,8 @@ export class Wallet { private readonly store: IKeyValueStorage, private readonly walletStore: IArrayStorage, private readonly sessionStore: IObjectStorage, + private readonly multisigStore: IArrayStorage, + private readonly pendingMultisigStore: IArrayStorage, private readonly loadContracts: LoadContracts, private readonly getNetwork: GetNetwork, ) {} @@ -227,11 +246,10 @@ export class Wallet { network: Network, accountType: ArgentAccountType, ): Promise { - if (network.accountClassHash) { + if (network.accountClassHash && network.accountClassHash.standard) { return ( - network.accountClassHash[ - mapArgentAccountTypeToImplementationKey(accountType) - ] ?? network.accountClassHash.argentAccount + network.accountClassHash[accountType] ?? + network.accountClassHash.standard ) } @@ -258,13 +276,21 @@ export class Wallet { const accounts: WalletAccount[] = [] - const networkAccountClassHash = await this.getAccountClassHashForNetwork( + const standardAccountClassHash = await this.getAccountClassHashForNetwork( network, - "argent", + "standard", + ) + + // This will be a standard account hash if multisig is not supported on the network + // It will be handled by the union function below + const multisigAccountClassHash = await this.getAccountClassHashForNetwork( + network, + "multisig", ) const accountClassHashes = union(ARGENT_ACCOUNT_CONTRACT_CLASS_HASHES, [ - networkAccountClassHash, + standardAccountClassHash, + multisigAccountClassHash, ]) const proxyClassHashes = PROXY_CONTRACT_CLASS_HASHES @@ -303,21 +329,59 @@ export class Wallet { 0, ) + const account: WalletAccount = { + name: "Unnamed Account", + address, + networkId: network.id, + network, + signer: { + type: "local_secret", + derivationPath: getPathForIndex(lastCheck, baseDerivationPath), + }, + type: "standard", + needsDeploy: false, // Only deployed accounts will be recovered + } + const code = await provider.getCode(address) if (code.bytecode.length > 0) { lastHit = lastCheck - accounts.push({ - address, - networkId: network.id, + accounts.push(account) // add a standard account + } else if ( + isEqualAddress(accountClassHash, multisigAccountClassHash) // this is required to ensure multisig accounts are only checked on networks that support them + ) { + // If it's not a standard account, check if the signer is a part of a Multisig + const multisigData = await fetchMultisigDataForSigner({ + signer: starkPub, network, - signer: { - type: "local_secret", - derivationPath: getPathForIndex(lastCheck, baseDerivationPath), - }, - type: "argent", - needsDeploy: false, // Only deployed accounts will be recovered }) + + // If the signer is not a part of multisig, the api doesn't throw an error + // but returns an empty content array + if (!isEmpty(multisigData.content)) { + lastHit = lastCheck + const { + address: multisigAddress, + creator, + signers, + threshold, + } = multisigData.content[0] + + accounts.push({ + ...account, + type: "multisig", + address: multisigAddress, + }) // add a multisig account + + await this.multisigStore.push({ + address: multisigAddress, + networkId: network.id, + signers, + threshold, + creator, + publicKey: starkPub, + }) + } } ++lastCheck @@ -340,7 +404,12 @@ export class Wallet { accountDetailFetchers, ) - return accountsWithDetails + const accountDetailsWithNames = + updateAccountsWithNames(accountsWithDetails) + + await this.walletStore.push(accountDetailsWithNames) + + return accountDetailsWithNames } catch (error) { console.error( "Error getting account types or guardians from chain", @@ -426,7 +495,37 @@ export class Wallet { await this.walletStore.push(accounts) } - public async newAccount(networkId: string): Promise { + public async getDefaultAccountName( + networkId: string, + type: CreateAccountType, + ): Promise { + const accounts = await this.walletStore.get(withHiddenSelector) + const pendingMultisigs = await this.pendingMultisigStore.get() + + const networkAccounts = accounts.filter( + (account) => account.networkId === networkId, + ) + + const [multisigs, standards] = partition( + networkAccounts, + (account) => account.type === "multisig", + ) + + const allMultisigs = [...multisigs, ...pendingMultisigs] + + const defaultAccountName = + type === "multisig" + ? `Multisig ${allMultisigs.length + 1}` + : `Account ${standards.length + 1}` + + return defaultAccountName + } + + public async newAccount( + networkId: string, + type: CreateAccountType = "standard", // Should not be able to create plugin accounts. Default to argent account + multisigPayload?: MultisigData, + ): Promise { const session = await this.sessionStore.get() if (!this.isSessionOpen() || !session) { throw Error("no open session") @@ -435,20 +534,34 @@ export class Wallet { const network = await this.getNetwork(networkId) const accounts = await this.walletStore.get(withHiddenSelector) + const pendingMultisigs = await this.pendingMultisigStore.get() + + const accountsOrPendingMultisigs = [...accounts, ...pendingMultisigs] - const currentPaths = accounts + const currentPaths = accountsOrPendingMultisigs .filter( (account) => account.signer.type === "local_secret" && - account.network.id === networkId, + account.networkId === networkId, ) .map((account) => account.signer.derivationPath) const index = getNextPathIndex(currentPaths, baseDerivationPath) - const { addressSalt, constructorCalldata } = - await this.getDeployContractPayloadForAccountIndex(index, networkId) + let payload + if (type === "multisig" && multisigPayload) { + payload = await this.getDeployContractPayloadForMultisig({ + index, + networkId, + ...multisigPayload, + }) + } else { + payload = await this.getDeployContractPayloadForAccountIndex( + index, + networkId, + ) + } const proxyClassHash = PROXY_CONTRACT_CLASS_HASHES[0] const proxyAddress = calculateContractAddressFromHash( @@ -458,7 +571,10 @@ export class Wallet { 0, ) - const account: WalletAccount = { + const defaultAccountName = await this.getDefaultAccountName(networkId, type) + + const account: CreateWalletAccount = { + name: defaultAccountName, network, networkId: network.id, address: proxyAddress, @@ -466,12 +582,23 @@ export class Wallet { type: "local_secret" as const, derivationPath: getPathForIndex(index, baseDerivationPath), }, - type: "argent", + type, needsDeploy: true, } await this.walletStore.push([account]) + if (type === "multisig" && multisigPayload) { + await this.multisigStore.push({ + address: account.address, + networkId: account.networkId, + signers: multisigPayload.signers, + threshold: multisigPayload.threshold, + creator: multisigPayload.creator, + publicKey: multisigPayload.publicKey, + }) + } + await this.selectAccount(account) return account @@ -487,9 +614,17 @@ export class Wallet { throw Error("Cannot deploy old accounts") } - const deployAccountPayload = await this.getAccountDeploymentPayload( - walletAccount, - ) + let deployAccountPayload: DeployAccountContractPayload + + if (walletAccount.type === "multisig") { + deployAccountPayload = await this.getMultisigDeploymentPayload( + walletAccount, + ) + } else { + deployAccountPayload = await this.getAccountDeploymentPayload( + walletAccount, + ) + } const { transaction_hash } = await starknetAccount.deployAccount( deployAccountPayload, @@ -510,9 +645,10 @@ export class Wallet { throw Error("Cannot estimate fee to deploy old accounts") } - const deployAccountPayload = await this.getAccountDeploymentPayload( - walletAccount, - ) + const deployAccountPayload = + walletAccount.type === "multisig" + ? await this.getMultisigDeploymentPayload(walletAccount) + : await this.getAccountDeploymentPayload(walletAccount) return starknetAccount.estimateAccountDeployFee(deployAccountPayload) } @@ -530,11 +666,14 @@ export class Wallet { return { account, txHash: deployTransaction.txHash } } + /** Get the Account Deployment Payload * Use it in the deployAccount and getAccountDeploymentFee methods * @param {WalletAccount} walletAccount */ - public async getAccountDeploymentPayload(walletAccount: WalletAccount) { + public async getAccountDeploymentPayload( + walletAccount: WalletAccount, + ): Promise> { const starkPair = await this.getKeyPairByDerivationPath( walletAccount.signer.derivationPath, ) @@ -543,7 +682,7 @@ export class Wallet { const accountClassHash = await this.getAccountClassHashForNetwork( walletAccount.network, - "argent", + walletAccount.type, ) const constructorCallData = { @@ -595,7 +734,61 @@ export class Wallet { return deployAccountPayload } - private async getDeployContractPayloadForAccountIndex( + public async getMultisigDeploymentPayload( + walletAccount: WalletAccount, + ): Promise> { + const multisigAccount = await getMultisigAccountFromBaseWallet( + walletAccount, + ) + + if (!multisigAccount) { + throw new Error("This multisig account does not exist") + } + + const starkPair = await this.getKeyPairByDerivationPath( + multisigAccount.signer.derivationPath, + ) + + const starkPub = ec.getStarkKey(starkPair) + + const accountClassHash = await this.getAccountClassHashForNetwork( + multisigAccount.network, + "multisig", // make sure to always use the multisig implementation + ) + + const constructorCallData = { + implementation: accountClassHash, + selector: getSelectorFromName("initialize"), + calldata: stark.compileCalldata({ + threshold: multisigAccount.threshold.toString(), + signers: multisigAccount.signers, + }), + } + + const deployMultisigPayload = { + classHash: PROXY_CONTRACT_CLASS_HASHES[0], + contractAddress: multisigAccount.address, + constructorCalldata: stark.compileCalldata(constructorCallData), + addressSalt: starkPub, + } + + // Mostly we don't need to calculate the address, + // but we do it here just to make sure the address is correct + const calculatedMultisigAddress = calculateContractAddressFromHash( + deployMultisigPayload.addressSalt, + deployMultisigPayload.classHash, + deployMultisigPayload.constructorCalldata, + 0, + ) + + if (!isEqualAddress(calculatedMultisigAddress, multisigAccount.address)) { + throw new Error("Calculated address does not match multisig address") + } + + return deployMultisigPayload + } + + public async getDeployContractPayloadForAccountIndex( index: number, networkId: string, ): Promise, "signature">> { @@ -616,7 +809,7 @@ export class Wallet { const accountClassHash = await this.getAccountClassHashForNetwork( network, - "argent", + "standard", ) const payload = { @@ -632,6 +825,54 @@ export class Wallet { return payload } + public async getDeployContractPayloadForMultisig({ + signers, + threshold, + index, + networkId, + }: { + threshold: number + signers: string[] + index: number + networkId: string + }): Promise> { + const hasSession = await this.isSessionOpen() + const session = await this.sessionStore.get() + const initialised = await this.isInitialized() + + if (!initialised) { + throw Error("wallet is not initialized") + } + if (!hasSession || !session) { + throw Error("no open session") + } + + const network = await this.getNetwork(networkId) + const starkPair = getStarkPair(index, session?.secret, baseDerivationPath) + const starkPub = ec.getStarkKey(starkPair) + + const accountClassHash = await this.getAccountClassHashForNetwork( + network, + "multisig", + ) + + const payload = { + classHash: accountClassHash, + constructorCalldata: stark.compileCalldata({ + implementation: accountClassHash, + selector: getSelectorFromName("initialize"), + calldata: stark.compileCalldata({ + threshold: threshold.toString(), + signers, + }), + }), + addressSalt: starkPub, + signature: starkPair.getPrivate(), + } + + return payload + } + public async getAccount(selector: BaseWalletAccount): Promise { const [hit] = await this.walletStore.get((account) => accountsEqual(account, selector), @@ -642,6 +883,32 @@ export class Wallet { return hit } + public async getMultisigAccount( + selector: BaseWalletAccount, + ): Promise { + const [walletAccount] = await this.walletStore.get( + (account) => + accountsEqual(account, selector) && account.type === "multisig", + ) + if (!walletAccount) { + throw Error("multisig wallet account not found") + } + + const [multisigBaseWalletAccount] = await this.multisigStore.get( + (account) => accountsEqual(account, selector), + ) + + if (!multisigBaseWalletAccount) { + throw Error("multisig base wallet account not found") + } + + return { + ...walletAccount, + ...multisigBaseWalletAccount, + type: "multisig", + } + } + public async getKeyPairByDerivationPath(derivationPath: string) { const session = await this.sessionStore.get() if (!session?.secret) { @@ -666,9 +933,21 @@ export class Wallet { return new GuardianSignerArgentX(keyPair, cosignerSign) } + // Return Multisig Signer if account is multisig + if (account.type === "multisig") { + return new MultisigSigner(keyPair) + } + return keyPair } + public getStarknetAccountOfType(account: Account, type: ArgentAccountType) { + if (type === "multisig") { + return MultisigAccount.fromAccount(account) + } + return account + } + public async getStarknetAccount( selector: BaseWalletAccount, useLatest = false, @@ -690,7 +969,9 @@ export class Wallet { const signer = await this.getSignerForAccount(account) if (account.needsDeploy || useLatest) { - return new Account(provider, account.address, signer) + const starknetAccount = new Account(provider, account.address, signer) + + return this.getStarknetAccountOfType(starknetAccount, account.type) } const providerV4 = getProviderv4( @@ -706,9 +987,11 @@ export class Wallet { account, ) + const starknetAccount = new Account(provider, account.address, signer) + return isOldAccount ? oldAccount - : new Account(provider, account.address, signer) + : this.getStarknetAccountOfType(starknetAccount, account.type) } public async getCurrentImplementation( @@ -737,6 +1020,55 @@ export class Wallet { return this.getStarknetAccount(account) } + public async getCalculatedMultisigAddress( + baseMultisigAccount: BaseMultisigWalletAccount, + ): Promise { + const multisigAccount = await getMultisigAccountFromBaseWallet( + baseMultisigAccount, + ) + + if (!multisigAccount) { + throw new Error("This multisig account does not exist") + } + + const starkPair = await this.getKeyPairByDerivationPath( + multisigAccount.signer.derivationPath, + ) + + const starkPub = ec.getStarkKey(starkPair) + + const accountClassHash = await this.getAccountClassHashForNetwork( + multisigAccount.network, + "multisig", // make sure to always use the multisig implementation + ) + + const decodedSigners = baseMultisigAccount.signers.map((signer) => + utils.hexlify(utils.base58.decode(signer)), + ) + + const constructorCallData = { + implementation: accountClassHash, + selector: getSelectorFromName("initialize"), + calldata: stark.compileCalldata({ + threshold: baseMultisigAccount.threshold.toString(), + signers: decodedSigners, + }), + } + + const deployMultisigPayload = { + classHash: PROXY_CONTRACT_CLASS_HASHES[0], + constructorCalldata: stark.compileCalldata(constructorCallData), + addressSalt: starkPub, + } + + return calculateContractAddressFromHash( + deployMultisigPayload.addressSalt, + deployMultisigPayload.classHash, + deployMultisigPayload.constructorCalldata, + 0, + ) + } + public async getSelectedAccount(): Promise { if (!this.isSessionOpen()) { return @@ -793,7 +1125,31 @@ export class Wallet { return { url, filename } } - public async getPublicKey(baseAccount?: BaseWalletAccount): Promise { + public async getPrivateKey( + baseWalletAccount: BaseWalletAccount, + ): Promise { + const session = await this.sessionStore.get() + if (!this.isSessionOpen() || !session?.secret) { + throw new Error("Session is not open") + } + + const account = await this.getAccount(baseWalletAccount) + + if (!account) { + throw new Error("no selected account") + } + + const starkPair = getStarkPair( + account.signer.derivationPath, + session.secret, + ) + + return starkPair.getPrivate().toString() + } + + public async getPublicKey( + baseAccount?: BaseWalletAccount, + ): Promise<{ publicKey: string; account: BaseWalletAccount }> { const account = baseAccount ? await this.getAccount(baseAccount) : await this.getSelectedAccount() @@ -808,29 +1164,67 @@ export class Wallet { const starkPub = ec.getStarkKey(starkPair) - return starkPub + return { publicKey: starkPub, account } } - public async exportPrivateKey( - baseWalletAccount: BaseWalletAccount, - ): Promise { + /** + * Given networkId, returns the next public key that will be used for a new account + * @param networkId + * @returns Public key + */ + public async getNextPublicKey( + networkId: string, + ): Promise<{ derivationPath: string; publicKey: string }> { const session = await this.sessionStore.get() - if (!this.isSessionOpen() || !session?.secret) { - throw new Error("Session is not open") + + if (!session?.secret) { + throw Error("session is not open") } - const account = await this.getAccount(baseWalletAccount) + const accounts = await this.walletStore.get(withHiddenSelector) + const pendingMultisigs = await this.pendingMultisigStore.get() - if (!account) { - throw new Error("no selected account") + const accountsOrPendingMultisigs = [...accounts, ...pendingMultisigs] + + const currentPaths = accountsOrPendingMultisigs + .filter( + (account) => + account.signer.type === "local_secret" && + account.networkId === networkId, + ) + .sort(sortByDerivationPath) + .map((account) => account.signer.derivationPath) + + const index = getNextPathIndex(currentPaths, baseDerivationPath) + + const path = getPathForIndex(index, baseDerivationPath) + const starkPair = getStarkPair(index, session?.secret, baseDerivationPath) + + return { + derivationPath: path, + publicKey: ec.getStarkKey(starkPair), } + } - const starkPair = getStarkPair( - account.signer.derivationPath, - session.secret, - ) + public async newPendingMultisig(networkId: string): Promise { + const { derivationPath, publicKey } = await this.getNextPublicKey(networkId) - return starkPair.getPrivate().toString() + const name = await this.getDefaultAccountName(networkId, "multisig") + + const pendingMultisig: PendingMultisig = { + name, + networkId, + signer: { + type: "local_secret", + derivationPath, + }, + publicKey, + type: "multisig", + } + + await this.pendingMultisigStore.push(pendingMultisig) + + return pendingMultisig } public static validateBackup(backupString: string): boolean { diff --git a/packages/extension/src/background/walletSingleton.ts b/packages/extension/src/background/walletSingleton.ts new file mode 100644 index 000000000..b5de1eba2 --- /dev/null +++ b/packages/extension/src/background/walletSingleton.ts @@ -0,0 +1,19 @@ +import { accountStore } from "../shared/account/store" +import { + multisigBaseWalletStore, + pendingMultisigStore, +} from "../shared/multisig/store" +import { getNetwork } from "../shared/network" +import { old_walletStore } from "../shared/wallet/walletStore" +import { loadContracts } from "./accounts" +import { Wallet, sessionStore } from "./wallet" + +export const walletSingleton = new Wallet( + old_walletStore, + accountStore, + sessionStore, + multisigBaseWalletStore, + pendingMultisigStore, + loadContracts, + getNetwork, +) diff --git a/packages/extension/src/content.ts b/packages/extension/src/content.ts index d67efc530..6398b7394 100644 --- a/packages/extension/src/content.ts +++ b/packages/extension/src/content.ts @@ -1,4 +1,5 @@ import { Relayer, WindowMessenger } from "@argent/x-window" +import { relay } from "trpc-extension/relay" import browser from "webextension-polyfill" import { ExtensionMessenger } from "./shared/extensionMessenger" @@ -22,4 +23,9 @@ const portMessenger = new ExtensionMessenger(port) const bridge = new Relayer(windowMessenger, portMessenger) // Please keep this log statement, it is used to detect if the bridge is loaded -console.log("Bridge ID:", bridge.id) +console.log("Legacy Bridge ID:", bridge.id) + +// NOTE: not used yet, as trpc is only used for UI <-> Background comms atm +const unsub = relay(window, port) +// unsub on content script unload +window.addEventListener("unload", unsub) diff --git a/packages/extension/src/inpage/ArgentXAccount.ts b/packages/extension/src/inpage/ArgentXAccount.ts index a4f9c852f..ea30dc371 100644 --- a/packages/extension/src/inpage/ArgentXAccount.ts +++ b/packages/extension/src/inpage/ArgentXAccount.ts @@ -144,6 +144,6 @@ export class ArgentXAccount extends Account { throw Error("User action timed out") } - return [result.r, result.s] + return result.signature } } diff --git a/packages/extension/src/inpage/ArgentXAccount3.ts b/packages/extension/src/inpage/ArgentXAccount3.ts index cda5543f0..97f5c5011 100644 --- a/packages/extension/src/inpage/ArgentXAccount3.ts +++ b/packages/extension/src/inpage/ArgentXAccount3.ts @@ -101,7 +101,7 @@ export class ArgentXAccount3 extends Account { throw Error("User action timed out") } - return [result.r, result.s] + return result.signature } } diff --git a/packages/extension/src/inpage/messaging.ts b/packages/extension/src/inpage/messaging.ts index 288e83af5..1183bb635 100644 --- a/packages/extension/src/inpage/messaging.ts +++ b/packages/extension/src/inpage/messaging.ts @@ -13,16 +13,3 @@ export const getIsPreauthorized = async () => { } return false } - -export const getNetwork = async (networkId: string) => { - try { - sendMessage({ - type: "GET_NETWORK", - data: networkId, - }) - return await waitForMessage("GET_NETWORK_RES", 2000) - } catch (error) { - console.error(`Error getting network: ${error} for networkId: ${networkId}`) - throw error - } -} diff --git a/packages/extension/src/inpage/requestMessageHandlers.ts b/packages/extension/src/inpage/requestMessageHandlers.ts index d2e3809de..adbad3af6 100644 --- a/packages/extension/src/inpage/requestMessageHandlers.ts +++ b/packages/extension/src/inpage/requestMessageHandlers.ts @@ -1,9 +1,6 @@ -import type { - AddStarknetChainParameters, - WatchAssetParameters, -} from "@argent/x-window" +import type { WatchAssetParameters } from "@argent/x-window" -import type { Network } from "../shared/network" +import type { Network } from "../shared/network/type" import { sendMessage, waitForMessage } from "./messageActions" export async function handleAddTokenRequest( @@ -55,66 +52,6 @@ export async function handleAddTokenRequest( return true } -export async function handleAddNetworkRequest( - callParams: AddStarknetChainParameters, -): Promise { - sendMessage({ - type: "REQUEST_ADD_CUSTOM_NETWORK", - data: { - id: callParams.id, - name: callParams.chainName, - chainId: callParams.chainId, - baseUrl: callParams.baseUrl, - rpcUrl: callParams.rpcUrls?.[0], - explorerUrl: callParams.blockExplorerUrls?.[0], - accountClassHash: (callParams as any).accountImplementation, - }, - }) - - const req = await Promise.race([ - waitForMessage("REQUEST_ADD_CUSTOM_NETWORK_RES", 1000), - waitForMessage("REQUEST_ADD_CUSTOM_NETWORK_REJ", 1000), - ]) - - if ("error" in req) { - throw Error(req.error) - } - - const { actionHash } = req - - sendMessage({ type: "OPEN_UI" }) - - const result = await Promise.race([ - waitForMessage( - "APPROVE_REQUEST_ADD_CUSTOM_NETWORK", - 11 * 60 * 1000, - (x) => x.data.actionHash === actionHash, - ), - waitForMessage( - "REJECT_REQUEST_ADD_CUSTOM_NETWORK", - 10 * 60 * 1000, - (x) => x.data.actionHash === actionHash, - ) - .then(() => "error" as const) - .catch(() => { - sendMessage({ - type: "REJECT_REQUEST_ADD_CUSTOM_NETWORK", - data: { actionHash }, - }) - return "timeout" as const - }), - ]) - - if (result === "error") { - throw Error("User abort") - } - if (result === "timeout") { - throw Error("User action timed out") - } - - return true -} - export async function handleSwitchNetworkRequest(callParams: { chainId: Network["chainId"] }): Promise { diff --git a/packages/extension/src/inpage/starknetWindowObject.ts b/packages/extension/src/inpage/starknetWindowObject.ts index 9d350ac69..fce8f43f9 100644 --- a/packages/extension/src/inpage/starknetWindowObject.ts +++ b/packages/extension/src/inpage/starknetWindowObject.ts @@ -8,11 +8,9 @@ import type { import { assertNever } from "./../ui/services/assertNever" import { getProvider } from "../shared/network/provider" import { ArgentXAccount } from "./ArgentXAccount" -import { ArgentXAccount3, getProvider3 } from "./ArgentXAccount3" import { sendMessage, waitForMessage } from "./messageActions" import { getIsPreauthorized } from "./messaging" import { - handleAddNetworkRequest, handleAddTokenRequest, handleSwitchNetworkRequest, } from "./requestMessageHandlers" @@ -40,7 +38,8 @@ export const starknetWindowObject: StarknetWindowObject = { ) { return await handleAddTokenRequest(call.params) } else if (call.type === "wallet_addStarknetChain" && "id" in call.params) { - return await handleAddNetworkRequest(call.params) + // TODO: implement + throw Error("Not implemented") } else if ( call.type === "wallet_switchStarknetChain" && "chainId" in call.params @@ -49,7 +48,7 @@ export const starknetWindowObject: StarknetWindowObject = { } throw Error("Not implemented") }, - enable: async ({ starknetVersion = "v3" } = {}) => { + enable: async ({ starknetVersion = "v4" } = {}) => { const walletAccountP = Promise.race([ waitForMessage("CONNECT_DAPP_RES", 10 * 60 * 1000), waitForMessage("REJECT_PREAUTHORIZATION", 10 * 60 * 1000).then( @@ -80,10 +79,9 @@ export const starknetWindowObject: StarknetWindowObject = { starknet.provider = provider starknet.account = new ArgentXAccount(address, provider) } else { - const provider = getProvider3(network) - ;(starknet as any).starknetJsVersion = "v3" - ;(starknet as any).provider = provider - ;(starknet as any).account = new ArgentXAccount3(address, provider) + throw Error( + "ArgentX only supports Account from starknet.js v4. We ask the dApp developers to use latest get-starknet package", + ) } starknet.selectedAddress = address diff --git a/packages/extension/src/inpage/trpcClient.ts b/packages/extension/src/inpage/trpcClient.ts new file mode 100644 index 000000000..1e7d2cc42 --- /dev/null +++ b/packages/extension/src/inpage/trpcClient.ts @@ -0,0 +1,8 @@ +import { createTRPCProxyClient } from "@trpc/client" +import { windowLink } from "trpc-extension/link" + +import { AppRouter } from "../background/__new/router" + +export const inpageMessageClient = createTRPCProxyClient({ + links: [windowLink({ window })], +}) diff --git a/packages/extension/src/shared/__new/services/ui/implementation.test.ts b/packages/extension/src/shared/__new/services/ui/implementation.test.ts new file mode 100644 index 000000000..d87230ee7 --- /dev/null +++ b/packages/extension/src/shared/__new/services/ui/implementation.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, test, vi } from "vitest" + +import UIService from "./implementation" + +describe("UIService", () => { + const makeService = () => { + const browser = { + browserAction: { + setPopup: vi.fn(), + }, + extension: { + getViews: vi.fn(), + }, + runtime: { + getURL: vi.fn(), + }, + tabs: { + create: vi.fn(), + query: vi.fn(), + update: vi.fn(), + }, + windows: { + update: vi.fn(), + }, + } + const uiService = new UIService(browser) + return { + uiService, + browser, + } + } + test("setDefaultPopup", async () => { + const { uiService, browser } = makeService() + await uiService.setDefaultPopup() + expect(browser.browserAction.setPopup).toHaveBeenCalledWith({ + popup: "index.html", + }) + }) + test("unsetDefaultPopup", async () => { + const { uiService, browser } = makeService() + await uiService.unsetDefaultPopup() + expect(browser.browserAction.setPopup).toHaveBeenCalledWith({ + popup: "", + }) + }) + describe("focusTab", () => { + test("when there is no tab", async () => { + const { uiService, browser } = makeService() + const getTabSpy = vi.spyOn(uiService, "getTab") + getTabSpy.mockImplementationOnce(async () => { + return {} as chrome.tabs.Tab + }) + await uiService.focusTab() + expect(getTabSpy).toHaveBeenCalled() + expect(browser.windows.update).not.toHaveBeenCalled() + expect(browser.tabs.update).not.toHaveBeenCalled() + }) + test("when there is a tab", async () => { + const { uiService, browser } = makeService() + const getTabSpy = vi.spyOn(uiService, "getTab") + getTabSpy.mockImplementationOnce(async () => { + return { id: "123", windowId: "abc" } as never as chrome.tabs.Tab + }) + await uiService.focusTab() + expect(getTabSpy).toHaveBeenCalled() + expect(browser.windows.update).toHaveBeenCalledWith("abc", { + focused: true, + }) + expect(browser.tabs.update).toHaveBeenCalledWith("123", { + active: true, + }) + }) + }) +}) diff --git a/packages/extension/src/shared/__new/services/ui/implementation.ts b/packages/extension/src/shared/__new/services/ui/implementation.ts new file mode 100644 index 000000000..27a74d999 --- /dev/null +++ b/packages/extension/src/shared/__new/services/ui/implementation.ts @@ -0,0 +1,71 @@ +import { DeepPick } from "../../../types/deepPick" +import type { IUIService } from "./interface" + +type MinimalBrowser = DeepPick< + typeof chrome, + | "browserAction.setPopup" + | "extension.getViews" + | "runtime.getURL" + | "tabs.create" + | "tabs.query" + | "tabs.update" + | "windows.update" +> + +export default class UIService implements IUIService { + constructor(private browser: MinimalBrowser) {} + + setDefaultPopup(popup = "index.html") { + return this.browser.browserAction.setPopup({ popup }) + } + + unsetDefaultPopup() { + return this.setDefaultPopup("") + } + + getPopup() { + const [popup] = this.browser.extension.getViews({ type: "popup" }) + return popup + } + + hasPopup() { + const popup = this.getPopup() + return Boolean(popup) + } + + closePopup() { + const popup = this.getPopup() + if (popup) { + popup.close() + } + } + + async createTab(path = "index.html") { + const url = this.browser.runtime.getURL(path) + return this.browser.tabs.create({ url }) + } + + async getTab() { + const [tab] = await this.browser.tabs.query({ + url: [this.browser.runtime.getURL("/*")], + }) + return tab + } + + async hasTab() { + const tab = await this.getTab() + return Boolean(tab && tab.id && tab.windowId) + } + + async focusTab() { + const tab = await this.getTab() + if (tab && tab.id && tab.windowId) { + await this.browser.windows.update(tab.windowId, { + focused: true, + }) + await this.browser.tabs.update(tab.id, { + active: true, + }) + } + } +} diff --git a/packages/extension/src/shared/__new/services/ui/index.ts b/packages/extension/src/shared/__new/services/ui/index.ts new file mode 100644 index 000000000..f3db7da7f --- /dev/null +++ b/packages/extension/src/shared/__new/services/ui/index.ts @@ -0,0 +1,5 @@ +import browser from "webextension-polyfill" + +import UIService from "./implementation" + +export const uiService = new UIService(browser) diff --git a/packages/extension/src/shared/__new/services/ui/interface.ts b/packages/extension/src/shared/__new/services/ui/interface.ts new file mode 100644 index 000000000..98f369dc2 --- /dev/null +++ b/packages/extension/src/shared/__new/services/ui/interface.ts @@ -0,0 +1,51 @@ +export interface IUIService { + /** + * The equivalent of setting or unsetting `default_popup` in the manifest + */ + setDefaultPopup(popup?: string): Promise + + /** + * Unsets popup so that extension icon click event can be captured + */ + unsetDefaultPopup(): Promise + + /** + * Get popup + * @returns popup if it exists + */ + getPopup(): Window + + /** + * Determine if there is an existing popup + * @returns true if it exists + */ + hasPopup(): boolean + + /** + * Close popup if it exists + */ + closePopup(): void + + /** + * Creates a tab with the provided path + * @returns tab + */ + createTab(path?: string): Promise + + /** + * Determine if there is an existing tab + * @returns true if it exists + */ + hasTab(): Promise + + /** + * Get existing tab + * @returns tab if it exists + */ + getTab(): Promise + + /** + * Focus existing tab (and window) if it exists + */ + focusTab(): Promise +} diff --git a/packages/extension/src/shared/account/details/getAccountTypesFromChain.ts b/packages/extension/src/shared/account/details/getAccountTypesFromChain.ts index 1679f6ea3..957e04d8c 100644 --- a/packages/extension/src/shared/account/details/getAccountTypesFromChain.ts +++ b/packages/extension/src/shared/account/details/getAccountTypesFromChain.ts @@ -41,6 +41,19 @@ export async function getAccountTypesFromChain( > => { const network = await getNetwork(networkId) + const hasOnlyArgentAccounts = + network.accountClassHash && + Object.entries(network.accountClassHash).every( + ([key, value]) => key === "argent" || value === undefined, + ) + + if (hasOnlyArgentAccounts) { + return calls.map((call) => ({ + address: call.contractAddress, + type: "standard", + })) + } + if (network.multicallAddress) { const multicall = getMulticallForNetwork(network) const responses = await Promise.all( @@ -48,10 +61,8 @@ export async function getAccountTypesFromChain( ) const result = responses.map((response, i) => { const call = calls[i] - const type = mapImplementationToArgentAccountType( - response[0], - network, - ) + const type: ArgentAccountType = + mapImplementationToArgentAccountType(response[0], network) return { address: call.contractAddress, type, diff --git a/packages/extension/src/shared/account/details/getAndMergeAccountDetails.test.ts b/packages/extension/src/shared/account/details/getAndMergeAccountDetails.test.ts index 27eb1632b..a65f8007a 100644 --- a/packages/extension/src/shared/account/details/getAndMergeAccountDetails.test.ts +++ b/packages/extension/src/shared/account/details/getAndMergeAccountDetails.test.ts @@ -26,7 +26,7 @@ describe("getAndMergeAccountDetails", () => { ): Promise => { return accounts.map((account) => ({ ...account, - type: account.address === address1 ? "argent" : "argent-plugin", + type: account.address === address1 ? "standard" : "plugin", })) } const getAccountGuardiansFromChain = async ( @@ -48,13 +48,13 @@ describe("getAndMergeAccountDetails", () => { "address": "0x7e00d496e324876bbc8531f2d9a82bf154d1a04a50218ee74cdd372f75a551a", "guardian": "0x1", "networkId": "goerli-alpha", - "type": "argent", + "type": "standard", }, { "address": "0x69b49c2cc8b16e80e86bfc5b0614a59aa8c9b601569c7b80dde04d3f3151b79", "guardian": "0x2", "networkId": "mainnet-alpha", - "type": "argent-plugin", + "type": "plugin", }, ] `) diff --git a/packages/extension/src/shared/account/details/getEscape.ts b/packages/extension/src/shared/account/details/getEscape.ts index fcee6d9c8..d575a36f3 100644 --- a/packages/extension/src/shared/account/details/getEscape.ts +++ b/packages/extension/src/shared/account/details/getEscape.ts @@ -1,4 +1,5 @@ import { Call, number } from "starknet" +import { z } from "zod" import { getMulticallForNetwork } from "../../multicall" import { getNetwork } from "../../network" @@ -12,11 +13,16 @@ export const ESCAPE_TYPE_SIGNER = 2 export const ESCAPE_SECURITY_PERIOD_DAYS = 7 -export interface Escape { +export const escapeSchema = z.object({ /** Time stamp escape will be active, in seconds */ - activeAt: number - type: typeof ESCAPE_TYPE_GUARDIAN | typeof ESCAPE_TYPE_SIGNER -} + activeAt: z.number(), + type: z.union([ + z.literal(ESCAPE_TYPE_GUARDIAN), + z.literal(ESCAPE_TYPE_SIGNER), + ]), +}) + +export type Escape = z.infer /** * Get escape state from account diff --git a/packages/extension/src/shared/account/details/updateAccountsWithNames.ts b/packages/extension/src/shared/account/details/updateAccountsWithNames.ts new file mode 100644 index 000000000..30ca64352 --- /dev/null +++ b/packages/extension/src/shared/account/details/updateAccountsWithNames.ts @@ -0,0 +1,22 @@ +import { partition } from "lodash-es" + +import { WalletAccount } from "../../wallet.model" + +export const updateAccountsWithNames = (accounts: WalletAccount[]) => { + const [multisigAccounts, standardAccounts] = partition( + accounts, + (account) => account.type === "multisig", + ) + + const updatedMultisigAccounts = multisigAccounts.map((multisig, index) => ({ + ...multisig, + name: `Multisig ${index + 1}`, + })) + + const updatedStandardAccounts = standardAccounts.map((account, index) => ({ + ...account, + name: `Account ${index + 1}`, + })) + + return [...updatedStandardAccounts, ...updatedMultisigAccounts] +} diff --git a/packages/extension/src/shared/account/service/implementation.test.ts b/packages/extension/src/shared/account/service/implementation.test.ts new file mode 100644 index 000000000..9647e5630 --- /dev/null +++ b/packages/extension/src/shared/account/service/implementation.test.ts @@ -0,0 +1,82 @@ +import { messageClient } from "../../../ui/services/messaging/trpc" +import { + MockFnObjectStore, + MockFnRepository, +} from "../../storage/__new/__test__/mockFunctionImplementation" +import type { BaseWalletAccount, WalletAccount } from "../../wallet.model" +import type { IWalletStore } from "../../wallet/walletStore" +import { AccountService } from "./implementation" + +describe("AccountService", () => { + let accountRepo: MockFnRepository + let walletStore: IWalletStore + let accountService: AccountService + + beforeEach(() => { + accountRepo = new MockFnRepository() + walletStore = new MockFnObjectStore() + accountService = new AccountService(accountRepo, walletStore, messageClient) + }) + + describe("select", () => { + it("should update wallet store with selected account", async () => { + const baseAccount: BaseWalletAccount = { + address: "0x123", + networkId: "0x1", + // @ts-expect-error extraValue is not part of BaseWalletAccount + extraValue: "extraValue", + } + await accountService.select(baseAccount) + + expect(walletStore.set).toHaveBeenCalledWith({ + selected: { + address: baseAccount.address, + networkId: baseAccount.networkId, + }, + }) + }) + + it("should set selected account to null if baseAccount is null", async () => { + await accountService.select(null) + + expect(walletStore.set).toHaveBeenCalledWith({ selected: null }) + }) + }) + + describe("get", () => { + it("should return accounts based on the provided selector", async () => { + const accounts: WalletAccount[] = [ + { address: "0x123", networkId: "0x1", name: "test1" } as WalletAccount, + ] + accountRepo.get.mockResolvedValue(accounts) + + const result = await accountService.get() + + expect(accountRepo.get).toHaveBeenCalled() + expect(result).toEqual(accounts) + }) + }) + + describe("upsert", () => { + it("should upsert accounts to the accountRepo", async () => { + const accounts: WalletAccount[] = [ + /* mock array of WalletAccount */ + ] + await accountService.upsert(accounts) + + expect(accountRepo.upsert).toHaveBeenCalledWith(accounts) + }) + }) + + describe("remove", () => { + it("should remove accounts from the accountRepo", async () => { + const baseAccount: BaseWalletAccount = { + address: "0x123", + networkId: "0x1", + } + await accountService.remove(baseAccount) + + expect(accountRepo.remove).toHaveBeenCalled() + }) + }) +}) diff --git a/packages/extension/src/shared/account/service/implementation.ts b/packages/extension/src/shared/account/service/implementation.ts new file mode 100644 index 000000000..65a91bdeb --- /dev/null +++ b/packages/extension/src/shared/account/service/implementation.ts @@ -0,0 +1,152 @@ +import { Account } from "../../../ui/features/accounts/Account" +import { Multisig } from "../../../ui/features/multisig/Multisig" +import { deployNewMultisig } from "../../../ui/services/backgroundAccounts" +import { messageClient } from "../../../ui/services/messaging/trpc" +import type { AllowArray, SelectorFn } from "../../storage/__new/interface" +import type { + ArgentAccountType, + BaseWalletAccount, + CreateAccountType, + MultisigData, + WalletAccount, +} from "../../wallet.model" +import { accountsEqual } from "../../wallet.service" +import type { IWalletStore } from "../../wallet/walletStore" +import { withoutHiddenSelector } from "../selectors" +import type { IAccountRepo } from "../store" +import type { IAccountService } from "./interface" + +// TODO: once the data presentation of account changes, this should be updated and tests should be added +// TODO: once the messaging is trpc, we should add tests +export class AccountService implements IAccountService { + constructor( + private readonly accountRepo: IAccountRepo, + private readonly walletStore: IWalletStore, + private readonly trpcClient: typeof messageClient, + ) {} + + async select(baseAccount: BaseWalletAccount | null): Promise { + return this.walletStore.set({ + selected: baseAccount + ? { + // pick values from baseAccount to avoid leaking additional data into storage + address: baseAccount.address, + networkId: baseAccount.networkId, + } + : null, + }) + } + + async create( + type: CreateAccountType, + networkId: string, + multisigPayload?: MultisigData, + ): Promise { + if (type === "multisig" && !multisigPayload) { + throw new Error("Multisig payload is required") + } + + let newAccount: Account + if (type === "multisig") { + // get rid of these extra abstractions + newAccount = await Multisig.create(networkId, multisigPayload) + } else { + newAccount = await Account.create(networkId, type) + } + + // get WalletAccount format + const [hit] = await this.accountRepo.get((account) => + accountsEqual(account, newAccount), + ) + + if (!hit) { + throw new Error("Something went wrong") + } + + // switch background wallet to the account that was selected + await this.select(newAccount) + + return hit + } + + // TODO: make isomorphic + async deploy(baseAccount: BaseWalletAccount): Promise { + const [account] = await this.accountRepo.get((account) => + accountsEqual(account, baseAccount), + ) + + if (!account) { + throw new Error("Account not found") + } + + if (account.needsDeploy === false) { + throw new Error("Account already deployed") + } + + if (account.type === "multisig") { + // TODO refactor this when multisig is stable + await deployNewMultisig(account) + } else { + await this.trpcClient.account.deploy.mutate(account) + } + } + + // TODO: make isomorphic + async upgrade( + baseAccount: BaseWalletAccount, + targetImplementationType?: ArgentAccountType | undefined, + ): Promise { + return this.trpcClient.account.upgrade.mutate({ + account: baseAccount, + targetImplementationType, + }) + } + + async get( + selector: SelectorFn = withoutHiddenSelector, + ): Promise { + return this.accountRepo.get(selector) + } + + async upsert(account: AllowArray): Promise { + await this.accountRepo.upsert(account) + } + + async remove(baseAccount: BaseWalletAccount): Promise { + await this.accountRepo.remove((account) => + accountsEqual(account, baseAccount), + ) + } + + // TBD: should we expose this function and get rid of one function per property? Or should we keep it as is? + private async update( + selector: SelectorFn, + updateFn: (account: WalletAccount) => WalletAccount, + ): Promise { + await this.accountRepo.upsert((accounts) => { + return accounts.map((account) => { + if (selector(account)) { + return updateFn(account) + } + return account + }) + }) + } + + async setHide( + hidden: boolean, + baseAccount: BaseWalletAccount, + ): Promise { + return this.update( + (account) => accountsEqual(account, baseAccount), + (account) => ({ ...account, hidden }), + ) + } + + async setName(name: string, baseAccount: BaseWalletAccount): Promise { + return this.update( + (account) => accountsEqual(account, baseAccount), + (account) => ({ ...account, name }), + ) + } +} diff --git a/packages/extension/src/shared/account/service/index.ts b/packages/extension/src/shared/account/service/index.ts new file mode 100644 index 000000000..8b6ef087b --- /dev/null +++ b/packages/extension/src/shared/account/service/index.ts @@ -0,0 +1,10 @@ +import { messageClient } from "../../../ui/services/messaging/trpc" +import { walletStore } from "../../wallet/walletStore" +import { accountRepo } from "../store" +import { AccountService } from "./implementation" + +export const accountService = new AccountService( + accountRepo, + walletStore, + messageClient, +) diff --git a/packages/extension/src/shared/account/service/interface.ts b/packages/extension/src/shared/account/service/interface.ts new file mode 100644 index 000000000..59c516ca0 --- /dev/null +++ b/packages/extension/src/shared/account/service/interface.ts @@ -0,0 +1,34 @@ +import { AllowArray, SelectorFn } from "../../storage/__new/interface" +import { + ArgentAccountType, + BaseWalletAccount, + CreateAccountType, + MultisigData, + WalletAccount, +} from "../../wallet.model" + +export interface IAccountService { + // selected account + select(baseAccount: BaseWalletAccount): Promise + + // account methods + create( + type: CreateAccountType, + networkId: string, + multisigPayload?: MultisigData, + ): Promise + deploy(baseAccount: BaseWalletAccount): Promise + upgrade( + baseAccount: BaseWalletAccount, + targetImplementationType?: ArgentAccountType, + ): Promise + + // Repo methods + get(selector: SelectorFn): Promise + upsert(account: AllowArray): Promise + remove(baseAccount: BaseWalletAccount): Promise + + // mutations/updates + setHide(hidden: boolean, baseAccount: BaseWalletAccount): Promise + setName(name: string, baseAccount: BaseWalletAccount): Promise +} diff --git a/packages/extension/src/shared/account/store.ts b/packages/extension/src/shared/account/store.ts deleted file mode 100644 index 5ccd5830c..000000000 --- a/packages/extension/src/shared/account/store.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { ArrayStorage } from "../storage" -import { AllowArray, SelectorFn } from "../storage/types" -import { BaseWalletAccount, WalletAccount } from "../wallet.model" -import { accountsEqual } from "../wallet.service" -import { getAccountSelector, withoutHiddenSelector } from "./selectors" -import { deserialize, serialize } from "./serialize" - -export const accountStore = new ArrayStorage([], { - namespace: "core:accounts", - compare: accountsEqual, - serialize, - deserialize, -}) - -export async function getAccounts( - selector: SelectorFn = withoutHiddenSelector, -): Promise { - return accountStore.get(selector) -} - -export async function addAccounts( - account: AllowArray, -): Promise { - await accountStore.push(account) -} - -export async function removeAccount( - baseAccount: BaseWalletAccount, -): Promise { - await accountStore.remove((account) => accountsEqual(account, baseAccount)) -} - -export async function hideAccount( - baseAccount: BaseWalletAccount, -): Promise { - const [hit] = await getAccounts(getAccountSelector(baseAccount)) - if (!hit) { - return - } - await accountStore.push({ - ...hit, - hidden: true, - }) -} - -export async function unhideAccount( - baseAccount: BaseWalletAccount, -): Promise { - const [hit] = await getAccounts(getAccountSelector(baseAccount)) - if (!hit) { - return - } - await accountStore.push({ - ...hit, - hidden: false, - }) -} diff --git a/packages/extension/src/shared/account/store/index.ts b/packages/extension/src/shared/account/store/index.ts new file mode 100644 index 000000000..05ab6fc43 --- /dev/null +++ b/packages/extension/src/shared/account/store/index.ts @@ -0,0 +1,20 @@ +import { ArrayStorage } from "../../storage" +import type { IRepository } from "../../storage/__new/interface" +import { adaptArrayStorage } from "../../storage/__new/repository" +import type { WalletAccount } from "../../wallet.model" +import { accountsEqual } from "../../wallet.service" +import { deserialize, serialize } from "./serialize" + +export type IAccountRepo = IRepository + +/** + * @deprecated use `accountRepo` instead + */ +export const accountStore = new ArrayStorage([], { + namespace: "core:accounts", + compare: accountsEqual, + serialize, + deserialize, +}) + +export const accountRepo: IAccountRepo = adaptArrayStorage(accountStore) diff --git a/packages/extension/src/shared/account/store/serialize.test.ts b/packages/extension/src/shared/account/store/serialize.test.ts new file mode 100644 index 000000000..82fc19282 --- /dev/null +++ b/packages/extension/src/shared/account/store/serialize.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it, vi } from "vitest" + +import { defaultNetwork } from "../../network/defaults" +import type { StoredWalletAccount, WalletAccount } from "../../wallet.model" +import { deserialize, serialize } from "./serialize" + +// Mock getNetwork function +vi.mock("../../network", () => ({ + getNetwork: vi.fn((networkId) => { + expect(networkId).toEqual("goerli-alpha") + return Promise.resolve(defaultNetwork) + }), +})) + +const mockAccounts: WalletAccount[] = [ + { + name: "Account1", + type: "standard", + address: "0x1", + signer: { derivationPath: "1", type: "local_secret" }, + networkId: "goerli-alpha", + network: defaultNetwork, + }, +] + +const mockStoredAccounts: StoredWalletAccount[] = [ + { + name: "Account1", + type: "standard", + address: "0x1", + signer: { derivationPath: "1", type: "local_secret" }, + networkId: "goerli-alpha", + }, +] + +describe("Wallet Account Serialization and Deserialization", () => { + it("Should correctly serialize wallet accounts", () => { + const serializedAccounts = serialize(mockAccounts) + expect(serializedAccounts).toEqual(mockStoredAccounts) + }) + + it("Should correctly deserialize stored wallet accounts", async () => { + const deserializedAccounts = await deserialize(mockStoredAccounts) + expect(deserializedAccounts).toEqual(mockAccounts) + }) +}) diff --git a/packages/extension/src/shared/account/serialize.ts b/packages/extension/src/shared/account/store/serialize.ts similarity index 81% rename from packages/extension/src/shared/account/serialize.ts rename to packages/extension/src/shared/account/store/serialize.ts index 0e177aaf3..aa84a21c7 100644 --- a/packages/extension/src/shared/account/serialize.ts +++ b/packages/extension/src/shared/account/store/serialize.ts @@ -1,5 +1,5 @@ -import { getNetwork } from "../network" -import { StoredWalletAccount, WalletAccount } from "../wallet.model" +import { getNetwork } from "../../network" +import { StoredWalletAccount, WalletAccount } from "../../wallet.model" export function serialize(accounts: WalletAccount[]): StoredWalletAccount[] { return accounts.map((account) => { diff --git a/packages/extension/src/shared/account/storeMigration.ts b/packages/extension/src/shared/account/storeMigration.ts index 3d8ff5804..86c291a8f 100644 --- a/packages/extension/src/shared/account/storeMigration.ts +++ b/packages/extension/src/shared/account/storeMigration.ts @@ -4,7 +4,7 @@ import browser from "webextension-polyfill" import { getNetwork } from "../network" import { WalletAccount } from "../wallet.model" import { accountsEqual } from "../wallet.service" -import { addAccounts } from "./store" +import { accountService } from "./service" export async function migrateWalletAccounts() { try { @@ -19,7 +19,7 @@ export async function migrateWalletAccounts() { const oldAccounts: WalletAccount[] = JSON.parse(needsMigration) const [newAccounts] = await checkAccountsForMigration(oldAccounts) - await addAccounts(newAccounts) + await accountService.upsert(newAccounts) return browser.storage.local.remove("wallet:accounts") } catch (e) { console.error(e) diff --git a/packages/extension/src/shared/account/update.ts b/packages/extension/src/shared/account/update.ts index 91219260c..76968463c 100644 --- a/packages/extension/src/shared/account/update.ts +++ b/packages/extension/src/shared/account/update.ts @@ -1,4 +1,11 @@ -import { WalletAccount } from "./../wallet.model" +import { + BaseMultisigWalletAccount, + MultisigWalletAccount, + WalletAccount, +} from "./../wallet.model" +import { fetchMultisigAccountData } from "../multisig/multisig.service" +import { multisigBaseWalletStore } from "../multisig/store" +import { getMultisigAccounts } from "../multisig/utils/baseMultisig" import { ARGENT_SHIELD_ENABLED } from "../shield/constants" import { BaseWalletAccount } from "../wallet.model" import { accountsEqual } from "../wallet.service" @@ -9,15 +16,16 @@ import { DetailFetchers, getAndMergeAccountDetails, } from "./details/getAndMergeAccountDetails" -import { addAccounts, getAccounts } from "./store" +import { accountService } from "./service" type UpdateScope = "all" | "type" | "deploy" | "guardian" +// TODO: move into worker instead of calling it explicitly export async function updateAccountDetails( scope: UpdateScope, accounts?: BaseWalletAccount[], ) { - const allAccounts = await getAccounts((a) => + const allAccounts = await accountService.get((a) => accounts ? accounts.some((a2) => accountsEqual(a, a2)) : true, ) @@ -53,5 +61,35 @@ export async function updateAccountDetails( accountDetailFetchers, ) - await addAccounts(newAccountsWithDetails) // handles deduplication and updates + await accountService.upsert(newAccountsWithDetails) // handles deduplication and updates +} + +export async function updateMultisigAccountDetails( + accounts?: BaseWalletAccount[], +) { + const multisigAccounts = await getMultisigAccounts((a) => + accounts ? accounts.some((a2) => accountsEqual(a, a2)) : true, + ) + + const updater = async ({ + address, + networkId, + publicKey, + }: MultisigWalletAccount): Promise => { + const { content } = await fetchMultisigAccountData({ + address, + networkId, + }) + + return { + ...content, + address, + networkId, + publicKey, + } + } + + const updated = await Promise.all(multisigAccounts.map(updater)) + + await multisigBaseWalletStore.push(updated) // handles deduplication and updates } diff --git a/packages/extension/src/shared/actionQueue/types.ts b/packages/extension/src/shared/actionQueue/types.ts index 1726cba4e..d7b1e0250 100644 --- a/packages/extension/src/shared/actionQueue/types.ts +++ b/packages/extension/src/shared/actionQueue/types.ts @@ -7,6 +7,7 @@ import type { typedData, } from "starknet" +import { Network } from "../network" import { TransactionMeta } from "../transactions" import { BaseWalletAccount } from "../wallet.model" @@ -41,6 +42,10 @@ export type ActionItem = type: "DEPLOY_ACCOUNT_ACTION" payload: BaseWalletAccount } + | { + type: "DEPLOY_MULTISIG_ACTION" + payload: BaseWalletAccount + } | { type: "SIGN" payload: typedData.TypedData @@ -55,29 +60,9 @@ export type ActionItem = networkId?: string } } - | { - type: "REQUEST_ADD_CUSTOM_NETWORK" - payload: { - id: string - name: string - chainId: string // A 0x-prefixed hexadecimal string - baseUrl: string - explorerUrl?: string - accountImplementation?: string - rpcUrl?: string - } - } | { type: "REQUEST_SWITCH_CUSTOM_NETWORK" - payload: { - id: string - name: string - chainId: string // A 0x-prefixed hexadecimal string - baseUrl: string - explorerUrl?: string - accountImplementation?: string - rpcUrl?: string - } + payload: Network } | { type: "DECLARE_CONTRACT_ACTION" diff --git a/packages/extension/src/shared/analytics.ts b/packages/extension/src/shared/analytics.ts index 9ae8533a9..cd337fd6b 100644 --- a/packages/extension/src/shared/analytics.ts +++ b/packages/extension/src/shared/analytics.ts @@ -1,9 +1,11 @@ import { base64 } from "ethers/lib/utils" import { encode } from "starknet" import browser from "webextension-polyfill" -import create from "zustand" +import { create } from "zustand" import { persist } from "zustand/middleware" +import { CreateAccountType } from "./wallet.model" + const SEGMENT_TRACK_URL = "https://api.segment.io/v1/track" const SEGMENT_PAGE_URL = "https://api.segment.io/v1/page" @@ -47,11 +49,13 @@ export interface Events { | { status: "success" networkId: string + type: CreateAccountType } | { status: "failure" errorMessage: string networkId: string + type: CreateAccountType } deployAccount: | { @@ -64,6 +68,17 @@ export interface Events { errorMessage: string networkId: string } + deployMultisig: + | { + status: "success" + trigger: "sign" | "transaction" + networkId: string + } + | { + status: "failure" + errorMessage: string + networkId: string + } preauthorizeDapp: { host: string networkId: string @@ -263,6 +278,10 @@ export function getAnalytics( return { track: async (event, ...[data]) => { if (!SEGMENT_WRITE_KEY) { + console.groupCollapsed(`Analytics: ${event}`) + console.log("You see this log because no SEGMENT_WRITE_KEY is set") + console.log(data) + console.groupEnd() return } const payload = { @@ -316,7 +335,7 @@ interface ActiveStore extends ActiveStoreValues { update: (key: keyof ActiveStoreValues) => void } -export const activeStore = create( +export const activeStore = create()( persist( (set) => ({ lastOpened: 0, // defaults to tracking once when no value set yet diff --git a/packages/extension/src/shared/api/constants.ts b/packages/extension/src/shared/api/constants.ts index ee97fbed0..92bc31a1d 100644 --- a/packages/extension/src/shared/api/constants.ts +++ b/packages/extension/src/shared/api/constants.ts @@ -2,6 +2,8 @@ import { isString } from "lodash-es" import urlJoin from "url-join" export const ARGENT_API_BASE_URL = process.env.ARGENT_API_BASE_URL as string +export const ARGENT_MULTISIG_BASE_URL = process.env + .ARGENT_MULTISIG_BASE_URL as string export const ARGENT_API_ENABLED = isString(ARGENT_API_BASE_URL) && ARGENT_API_BASE_URL.length > 0 @@ -46,3 +48,12 @@ export const ARGENT_TRANSACTION_SIMULATION_URL = ARGENT_API_ENABLED export const ARGENT_TRANSACTION_SIMULATION_API_ENABLED = isString(ARGENT_TRANSACTION_SIMULATION_URL) && ARGENT_TRANSACTION_SIMULATION_URL.length > 0 + +export const ARGENT_MULTISIG_ENABLED = + process.env.FEATURE_MULTISIG === "true" && + isString(ARGENT_MULTISIG_BASE_URL) && + ARGENT_MULTISIG_BASE_URL.length > 0 + +export const ARGENT_MULTISIG_URL = ARGENT_MULTISIG_ENABLED + ? ARGENT_MULTISIG_BASE_URL + : undefined diff --git a/packages/extension/src/shared/call/changeMultisigSignersCall.ts b/packages/extension/src/shared/call/changeMultisigSignersCall.ts new file mode 100644 index 000000000..a9be8878d --- /dev/null +++ b/packages/extension/src/shared/call/changeMultisigSignersCall.ts @@ -0,0 +1,37 @@ +import { Call, validateAndParseAddress } from "starknet" + +export interface AddMultisigSignersCall extends Call { + entrypoint: "addSigners" +} + +export const isAddMultisigSignersCall = ( + call: Call, +): call is AddMultisigSignersCall => { + try { + if (call.contractAddress && call.entrypoint === "addSigners") { + validateAndParseAddress(call.contractAddress) + return true + } + } catch (e) { + // failure implies invalid + } + return false +} + +export interface RemoveMultisigSignersCall extends Call { + entrypoint: "removeSigners" +} + +export const isRemoveMultisigSignersCall = ( + call: Call, +): call is AddMultisigSignersCall => { + try { + if (call.contractAddress && call.entrypoint === "removeSigners") { + validateAndParseAddress(call.contractAddress) + return true + } + } catch (e) { + // failure implies invalid + } + return false +} diff --git a/packages/extension/src/shared/call/setMultisigThresholdCalls.ts b/packages/extension/src/shared/call/setMultisigThresholdCalls.ts new file mode 100644 index 000000000..6d627b99b --- /dev/null +++ b/packages/extension/src/shared/call/setMultisigThresholdCalls.ts @@ -0,0 +1,19 @@ +import { Call, validateAndParseAddress } from "starknet" + +export interface ChangeTresholdMultisigCall extends Call { + entrypoint: "changeThreshold" +} + +export const isChangeTresholdMultisigCall = ( + call: Call, +): call is ChangeTresholdMultisigCall => { + try { + if (call.contractAddress && call.entrypoint === "changeThreshold") { + validateAndParseAddress(call.contractAddress) + return true + } + } catch (e) { + // failure implies invalid + } + return false +} diff --git a/packages/extension/src/shared/explorer/type.ts b/packages/extension/src/shared/explorer/type.ts index 1a7554e38..7609d5a06 100644 --- a/packages/extension/src/shared/explorer/type.ts +++ b/packages/extension/src/shared/explorer/type.ts @@ -1,4 +1,4 @@ -import { Status } from "starknet" +import { ExtendedTransactionStatus } from "../transactions" export interface IExplorerTransactionParameters { /** @example tokenId @example response_len */ @@ -37,7 +37,7 @@ export interface IExplorerTransaction { maxFee?: string /** @example 0x490b183da37 */ actualFee: string - status: Status + status: ExtendedTransactionStatus statusData: string events: IExplorerTransactionEvent[] calls?: IExplorerTransactionCall[] diff --git a/packages/extension/src/shared/messages/AccountMessage.ts b/packages/extension/src/shared/messages/AccountMessage.ts index 3756a9031..b460ab6de 100644 --- a/packages/extension/src/shared/messages/AccountMessage.ts +++ b/packages/extension/src/shared/messages/AccountMessage.ts @@ -5,18 +5,6 @@ import { } from "../wallet.model" export type AccountMessage = - | { type: "NEW_ACCOUNT"; data: string } - | { - type: "NEW_ACCOUNT_RES" - data: { - account: WalletAccount - accounts: WalletAccount[] - } - } - | { type: "NEW_ACCOUNT_REJ"; data: { error: string } } - | { type: "DEPLOY_ACCOUNT"; data: BaseWalletAccount } - | { type: "DEPLOY_ACCOUNT_RES" } - | { type: "DEPLOY_ACCOUNT_REJ" } | { type: "DEPLOY_ACCOUNT_ACTION_SUBMITTED" data: { txHash: string; actionHash: string } @@ -25,9 +13,6 @@ export type AccountMessage = type: "DEPLOY_ACCOUNT_ACTION_FAILED" data: { actionHash: string; error?: string } } - | { type: "GET_ACCOUNTS"; data?: { showHidden: boolean } } - | { type: "GET_ACCOUNTS_RES"; data: WalletAccount[] } - | { type: "CONNECT_ACCOUNT"; data?: BaseWalletAccount } | { type: "CONNECT_ACCOUNT_RES"; data?: WalletAccount } | { type: "DISCONNECT_ACCOUNT" } | { type: "GET_SELECTED_ACCOUNT" } @@ -70,8 +55,19 @@ export type AccountMessage = } | { type: "GET_PUBLIC_KEY_RES" + data: { publicKey: string; account: BaseWalletAccount } + } + | { + type: "GET_NEXT_PUBLIC_KEY" + data: { networkId: string } + } + | { + type: "GET_NEXT_PUBLIC_KEY_RES" data: { publicKey: string } } + | { + type: "GET_NEXT_PUBLIC_KEY_REJ" + } | { type: "GET_ENCRYPTED_SEED_PHRASE" data: { encryptedSecret: string } @@ -82,7 +78,7 @@ export type AccountMessage = } | { type: "ACCOUNT_CHANGE_GUARDIAN" - data: { account: BaseWalletAccount; guardian: string | undefined } + data: { account: BaseWalletAccount; guardian: string } } | { type: "ACCOUNT_CHANGE_GUARDIAN_RES" diff --git a/packages/extension/src/shared/messages/ActionMessage.ts b/packages/extension/src/shared/messages/ActionMessage.ts index 8e6716e33..8269ca6fc 100644 --- a/packages/extension/src/shared/messages/ActionMessage.ts +++ b/packages/extension/src/shared/messages/ActionMessage.ts @@ -1,4 +1,4 @@ -import type { typedData } from "starknet" +import type { Signature, typedData } from "starknet" import { ExtensionActionItem } from "../actionQueue/types" @@ -15,5 +15,5 @@ export type ActionMessage = | { type: "SIGNATURE_FAILURE"; data: { actionHash: string } } | { type: "SIGNATURE_SUCCESS" - data: { r: string; s: string; actionHash: string } + data: { signature: Signature; actionHash: string } } diff --git a/packages/extension/src/shared/messages/MultisigMessage.ts b/packages/extension/src/shared/messages/MultisigMessage.ts new file mode 100644 index 000000000..97d2d8854 --- /dev/null +++ b/packages/extension/src/shared/messages/MultisigMessage.ts @@ -0,0 +1,74 @@ +import { + AddOwnerMultisigPayload, + RemoveOwnerMultisigPayload, + UpdateMultisigThresholdPayload, +} from "../multisig/multisig.model" +import { PendingMultisig } from "../multisig/types" +import { BaseWalletAccount, MultisigData, WalletAccount } from "../wallet.model" + +export type MultisigMessage = + | { + type: "NEW_MULTISIG_ACCOUNT" + data: MultisigData & { networkId: string } + } + | { + type: "NEW_MULTISIG_ACCOUNT_RES" + data: { + account: WalletAccount + accounts: WalletAccount[] + } + } + | { type: "NEW_MULTISIG_ACCOUNT_REJ"; data: { error: string } } + | { + type: "NEW_PENDING_MULTISIG" + data: { networkId: string } + } + | { type: "NEW_PENDING_MULTISIG_RES"; data: PendingMultisig } + | { type: "NEW_PENDING_MULTISIG_REJ"; data: { error: string } } + | { type: "DEPLOY_MULTISIG"; data: BaseWalletAccount } + | { type: "DEPLOY_MULTISIG_RES" } + | { type: "DEPLOY_MULTISIG_REJ" } + | { + type: "DEPLOY_MULTISIG_ACTION_SUBMITTED" + data: { txHash: string; actionHash: string } + } + | { + type: "DEPLOY_MULTISIG_ACTION_FAILED" + data: { actionHash: string; error?: string } + } + | { type: "ADD_MULTISIG_OWNERS"; data: AddOwnerMultisigPayload } + | { + type: "ADD_MULTISIG_OWNERS_REJ" + data: { error: string } + } + | { + type: "ADD_MULTISIG_OWNERS_RES" + } + | { type: "UPDATE_MULTISIG_THRESHOLD"; data: UpdateMultisigThresholdPayload } + | { + type: "UPDATE_MULTISIG_THRESHOLD_REJ" + data: { error: string } + } + | { + type: "UPDATE_MULTISIG_THRESHOLD_RES" + } + | { type: "REMOVE_MULTISIG_OWNER"; data: RemoveOwnerMultisigPayload } + | { + type: "REMOVE_MULTISIG_OWNER_REJ" + data: { error: string } + } + | { + type: "REMOVE_MULTISIG_OWNER_RES" + } + | { + type: "ADD_MULTISIG_TRANSACTION_SIGNATURE" + data: { requestId: string } + } + | { + type: "ADD_MULTISIG_TRANSACTION_SIGNATURE_RES" + data: { txHash: string } + } + | { + type: "ADD_MULTISIG_TRANSACTION_SIGNATURE_REJ" + data: { error: string } + } diff --git a/packages/extension/src/shared/messages/NetworkMessage.ts b/packages/extension/src/shared/messages/NetworkMessage.ts index 56ade20ac..efc60b956 100644 --- a/packages/extension/src/shared/messages/NetworkMessage.ts +++ b/packages/extension/src/shared/messages/NetworkMessage.ts @@ -1,26 +1,9 @@ -import { Network, NetworkStatus } from "../network" +import { Network } from "../network" import { WalletAccount } from "../wallet.model" export type NetworkMessage = - // ***** networks ***** - | { type: "GET_NETWORKS" } - | { type: "GET_NETWORKS_RES"; data: Network[] } - | { type: "GET_NETWORK"; data: Network["id"] } - | { type: "GET_NETWORK_RES"; data: Network } - | { type: "GET_CUSTOM_NETWORKS" } - | { type: "GET_CUSTOM_NETWORKS_RES"; data: Network[] } - | { type: "ADD_CUSTOM_NETWORKS"; data: Network[] } - | { type: "ADD_CUSTOM_NETWORKS_RES"; data: Network[] } - | { type: "REMOVE_CUSTOM_NETWORKS"; data: Network["id"][] } - | { type: "REMOVE_CUSTOM_NETWORKS_RES"; data: Network[] } - | { type: "GET_NETWORK_STATUSES"; data?: Network[] } // allows ui to get specific network statuses and defaults to all - | { - type: "GET_NETWORK_STATUSES_RES" - data: Partial> - } - // - used by dapps to request addition of custom network - | { type: "REQUEST_ADD_CUSTOM_NETWORK"; data: Network } + // | { type: "REQUEST_ADD_CUSTOM_NETWORK"; data: Network } | { type: "REQUEST_ADD_CUSTOM_NETWORK_RES"; data: { actionHash: string } } | { type: "REQUEST_ADD_CUSTOM_NETWORK_REJ" diff --git a/packages/extension/src/shared/messages/RecoveryMessage.ts b/packages/extension/src/shared/messages/RecoveryMessage.ts deleted file mode 100644 index 639cdedcf..000000000 --- a/packages/extension/src/shared/messages/RecoveryMessage.ts +++ /dev/null @@ -1,9 +0,0 @@ -export type RecoveryMessage = - | { type: "RECOVER_BACKUP"; data: string } - | { type: "RECOVER_BACKUP_RES" } - | { type: "RECOVER_BACKUP_REJ"; data: string } - | { type: "RECOVER_SEEDPHRASE"; data: { secure: true; body: string } } - | { type: "RECOVER_SEEDPHRASE_RES" } - | { type: "RECOVER_SEEDPHRASE_REJ"; data: string } - | { type: "DOWNLOAD_BACKUP_FILE" } - | { type: "DOWNLOAD_BACKUP_FILE_RES" } diff --git a/packages/extension/src/shared/messages/index.ts b/packages/extension/src/shared/messages/index.ts index 47de726d6..59f05811a 100644 --- a/packages/extension/src/shared/messages/index.ts +++ b/packages/extension/src/shared/messages/index.ts @@ -5,9 +5,9 @@ import { IS_DEV } from "../utils/dev" import { AccountMessage } from "./AccountMessage" import { ActionMessage } from "./ActionMessage" import { MiscenalleousMessage } from "./MiscellaneousMessage" +import { MultisigMessage } from "./MultisigMessage" import { NetworkMessage } from "./NetworkMessage" import { PreAuthorisationMessage } from "./PreAuthorisationMessage" -import { RecoveryMessage } from "./RecoveryMessage" import { SessionMessage } from "./SessionMessage" import { ShieldMessage } from "./ShieldMessage" import { TokenMessage } from "./TokenMessage" @@ -20,12 +20,12 @@ export type MessageType = | MiscenalleousMessage | NetworkMessage | PreAuthorisationMessage - | RecoveryMessage | SessionMessage | TokenMessage | TransactionMessage | UdcMessage | ShieldMessage + | MultisigMessage export type WindowMessageType = MessageType & { forwarded?: boolean @@ -50,6 +50,8 @@ export function sendMessage( return _sendMessage(cleanMessage, options) } +export type SendMessage = typeof sendMessage + export async function waitForMessage< K extends MessageType["type"], T extends { type: K } & MessageType, @@ -62,6 +64,8 @@ export async function waitForMessage< ).then(([msg]: any) => msg.data) } +export type WaitForMessage = typeof waitForMessage + if ((window).PLAYWRIGHT || IS_DEV) { ;(window).messageStream = messageStream ;(window).sendMessage = sendMessage diff --git a/packages/extension/src/shared/multisig/account.ts b/packages/extension/src/shared/multisig/account.ts new file mode 100644 index 000000000..1d2cfb1ba --- /dev/null +++ b/packages/extension/src/shared/multisig/account.ts @@ -0,0 +1,257 @@ +import { + Abi, + Account, + AllowArray, + Call, + InvocationsDetails, + InvocationsSignerDetails, + InvokeFunctionResponse, + KeyPair, + ProviderInterface, + ProviderOptions, + hash, + number, + transaction as starknetTransaction, +} from "starknet" +import { Account as AccountV4 } from "starknet4" +import urlJoin from "url-join" + +import { ARGENT_MULTISIG_URL } from "../api/constants" +import { fetcher } from "../api/fetcher" +import { + chainIdToStarknetNetwork, + starknetNetworkToNetworkId, +} from "../utils/starknetNetwork" +import { + ApiMultisigAddRequestSignatureSchema, + ApiMultisigPostRequestTxnSchema, + ApiMultisigTxnResponseSchema, +} from "./multisig.model" +import { + addToMultisigPendingTransactions, + cancelPendingMultisigTransactions, + getMultisigPendingTransaction, + multisigPendingTransactionToTransaction, +} from "./pendingTransactionsStore" +import { MultisigSigner } from "./signer" +import { getMultisigAccountFromBaseWallet } from "./utils/baseMultisig" + +export class MultisigAccount extends Account { + public readonly multsigBaseUrl?: string + + constructor( + providerOrOptions: ProviderInterface | ProviderOptions, + address: string, + keyPairOrSigner: MultisigSigner | KeyPair, + multisigBaseUrl?: string, + ) { + const multisigSigner = + "getPubKey" in keyPairOrSigner + ? keyPairOrSigner + : new MultisigSigner(keyPairOrSigner) + super(providerOrOptions, address, multisigSigner) + this.multsigBaseUrl = multisigBaseUrl ?? ARGENT_MULTISIG_URL + } + + static fromAccount(account: Account, baseUrl?: string): MultisigAccount { + return new MultisigAccount( + account, + account.address, + account.signer, + baseUrl, + ) + } + + static isMultisig(account: Account | AccountV4): account is MultisigAccount { + return "multsigBaseUrl" in account && "addRequestSignature" in account + } + + public async execute( + calls: AllowArray, + abis?: Abi[] | undefined, + transactionsDetail: InvocationsDetails = {}, + ): Promise { + if (!this.multsigBaseUrl) { + throw Error("Argent Multisig endpoint is not defined") + } + + const transactions = Array.isArray(calls) ? calls : [calls] + const nonce = number.toHex( + number.toBN(transactionsDetail.nonce ?? (await this.getNonce())), + ) + const version = number.toBN(hash.transactionVersion).toString() + const chainId = await this.getChainId() + + const maxFee = transactionsDetail.maxFee ?? "0x77d87d677d1a0" // TODO: implement estimateFee (also cant be 0) + + const signerDetails: InvocationsSignerDetails = { + walletAddress: this.address, + chainId, + nonce, + version, + maxFee, + } + + const signature = await this.signer.signTransaction( + transactions, + signerDetails, + abis, + ) + + const [creator, r, s] = signature.map(number.toHexString) + + const starknetNetwork = chainIdToStarknetNetwork(chainId) + const networkId = starknetNetworkToNetworkId(starknetNetwork) + + const txnWithHexCalldata = transactions.map((transaction) => ({ + ...transaction, + calldata: number.getHexStringArray(transaction.calldata ?? []), + })) + const request = ApiMultisigPostRequestTxnSchema.parse({ + creator, + transaction: { + nonce: number.toHexString(nonce), + version: number.toHexString(version), + // todo remove once we have 0.11 + maxFee: number.toHexString(maxFee), + calls: txnWithHexCalldata, + }, + starknetSignature: { r, s }, + signature: { r, s }, + }) + + const url = urlJoin( + this.multsigBaseUrl, + starknetNetwork, + this.address, + "request", + ) + + const response = await fetcher(url, { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + body: JSON.stringify(request), + }) + + const data = ApiMultisigTxnResponseSchema.parse(response) + + const computedTransactionHash = hash.calculateTransactionHash( + this.address, + version, + starknetTransaction.fromCallsToExecuteCalldata(transactions), + maxFee, + chainId, + nonce, + ) + + const transactionHash = + data.content.transactionHash ?? computedTransactionHash + + await addToMultisigPendingTransactions({ + ...data.content, + requestId: data.content.id, + timestamp: Date.now(), + type: "INVOKE_FUNCTION", + address: this.address, + networkId, + transactionHash, + notify: false, // Don't notify the creator of the transaction + }) + + return { + transaction_hash: transactionHash, + } + } + + public async addRequestSignature(requestId: string) { + if (!this.multsigBaseUrl) { + throw Error("Argent Multisig endpoint is not defined") + } + + const chainId = await this.getChainId() + const starknetNetwork = chainIdToStarknetNetwork(chainId) + const networkId = starknetNetworkToNetworkId(starknetNetwork) + + const pendingTransaction = await getMultisigPendingTransaction(requestId) + const multisig = await getMultisigAccountFromBaseWallet({ + address: this.address, + networkId, + }) + + if (!multisig) { + throw Error(`Multisig wallet with address ${this.address} not found`) + } + + if (!pendingTransaction) { + throw Error( + `Pending Multisig transaction with requestId ${requestId} not found`, + ) + } + + const { calls, maxFee, nonce, version } = pendingTransaction.transaction + + const signerDetails: InvocationsSignerDetails = { + walletAddress: this.address, + chainId, + nonce, + version, + maxFee, + } + + const signature = await this.signer.signTransaction(calls, signerDetails) + + const [signer, r, s] = signature.map(number.toHexString) + + const url = urlJoin( + this.multsigBaseUrl, + starknetNetwork, + this.address, + "request", + requestId, + "signature", + ) + + const request = ApiMultisigAddRequestSignatureSchema.parse({ + signer, + starknetSignature: { r, s }, + }) + + const response = await fetcher(url, { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + body: JSON.stringify(request), + }) + + const data = ApiMultisigTxnResponseSchema.parse(response) + + if (data.content.approvedSigners.length === multisig.threshold) { + await multisigPendingTransactionToTransaction( + data.content.id, + data.content.state, + ) + + await cancelPendingMultisigTransactions({ + address: this.address, + networkId, + }) + } else { + await addToMultisigPendingTransactions({ + ...pendingTransaction, + approvedSigners: data.content.approvedSigners, + nonApprovedSigners: data.content.nonApprovedSigners, + state: data.content.state, + notify: false, + }) + } + + return { + transaction_hash: pendingTransaction.transactionHash, + } + } +} diff --git a/packages/extension/src/shared/multisig/mocks/executeTransaction.mock.ts b/packages/extension/src/shared/multisig/mocks/executeTransaction.mock.ts new file mode 100644 index 000000000..d19986dfe --- /dev/null +++ b/packages/extension/src/shared/multisig/mocks/executeTransaction.mock.ts @@ -0,0 +1,35 @@ +export default { + content: { + id: "d3ac7ef8-a102-4550-8667-83b07e000f11", + multisigAddress: + "0x00b69d25639176e56e2207c1a9b8c28637a16225265b08d5df318b656a5bae87", + creator: + "0x07cd076ed8aef015ae2a470659a59b568589b9c0b84c3e5cfded6abc1cd2bbb7", + transaction: { + calls: [ + { + calldata: [ + "0xb69d25639176e56e2207c1a9b8c28637a16225265b08d5df318b656a5bae87", + "0x5af3107a4000", + "0x0", + ], + entrypoint: "transfer", + contractAddress: + "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + }, + ], + nonce: "0x2", + maxFee: "0x2d93021e7720", + version: "0x1", + }, + nonce: 2, + approvedSigners: [ + "0x07cd076ed8aef015ae2a470659a59b568589b9c0b84c3e5cfded6abc1cd2bbb7", + ], + nonApprovedSigners: [ + "0x008cc97d662506bd45756fe2187e52f5ae022a107db569437813d901767a4163", + "0x0000000000000000000000000000000000000000000000000000000000000002", + ], + state: "TX_ACCEPTED_L2", + }, +} diff --git a/packages/extension/src/shared/multisig/multisig.model.ts b/packages/extension/src/shared/multisig/multisig.model.ts new file mode 100644 index 000000000..fc2fe7365 --- /dev/null +++ b/packages/extension/src/shared/multisig/multisig.model.ts @@ -0,0 +1,130 @@ +import { z } from "zod" + +export const ApiMultisigContentSchema = z.object({ + address: z.string(), + creator: z.string(), + signers: z.array(z.string()), + threshold: z.number(), +}) + +export const ApiMultisigDataForSignerSchema = z.object({ + totalPages: z.number(), + totalElements: z.number(), + size: z.number(), + content: z.array(ApiMultisigContentSchema), +}) + +export const ApiMultisigCallSchema = z.object({ + contractAddress: z.string(), + entrypoint: z.string(), + calldata: z.array(z.string()).optional(), +}) + +export const ApiMultisigTransactionSchema = z.object({ + maxFee: z.string(), + nonce: z.string(), + version: z.string(), + calls: z.array(ApiMultisigCallSchema), +}) + +export const ApiMultisigStarknetSignature = z.object({ + r: z.string(), + s: z.string(), +}) + +export const ApiMultisigPostRequestTxnSchema = z.object({ + creator: z.string(), + transaction: ApiMultisigTransactionSchema, + starknetSignature: ApiMultisigStarknetSignature, + signature: ApiMultisigStarknetSignature, +}) + +export const ApiMultisigStateSchema = z.union([ + z.literal("AWAITING_SIGNATURES"), + z.literal("SUBMITTING"), + z.literal("SUBMITTED"), + z.literal("TX_PENDING"), + z.literal("TX_ACCEPTED_L2"), + z.literal("COMPLETE"), + z.literal("ERROR"), + z.literal("CANCELLED"), +]) + +export const ApiMultisigTxnResponseSchema = z.object({ + content: z.object({ + id: z.string(), + multisigAddress: z.string(), + creator: z.string(), + transaction: ApiMultisigTransactionSchema, + nonce: z.number(), + approvedSigners: z.array(z.string()), + nonApprovedSigners: z.array(z.string()), + state: ApiMultisigStateSchema, + transactionHash: z.string().optional(), + }), +}) + +export const ApiMultisigGetRequestsSchema = z.object({ + totalPages: z.number(), + totalElements: z.number(), + size: z.number(), + content: z.array( + z.object({ + id: z.string(), + multisigAddress: z.string(), + creator: z.string(), + transaction: ApiMultisigTransactionSchema, + nonce: z.number(), + approvedSigners: z.array(z.string()), + nonApprovedSigners: z.array(z.string()), + state: ApiMultisigStateSchema, + transactionHash: z.string().optional(), + }), + ), +}) + +export const ApiMultisigAddRequestSignatureSchema = z.object({ + signer: z.string(), + starknetSignature: ApiMultisigStarknetSignature, +}) + +export type ApiMultisigContent = z.infer +export type ApiMultisigDataForSigner = z.infer< + typeof ApiMultisigDataForSignerSchema +> +export type ApiMultisigCall = z.infer +export type ApiMultisigTransaction = z.infer< + typeof ApiMultisigTransactionSchema +> +export type ApiMultisigPostRequestTxn = z.infer< + typeof ApiMultisigPostRequestTxnSchema +> + +export type ApiMultisigGetRequests = z.infer< + typeof ApiMultisigGetRequestsSchema +> +export type ApiMultisigState = z.infer +export type ApiMultisigTxnResponse = z.infer< + typeof ApiMultisigTxnResponseSchema +> +export type ApiMultisigAddRequestSignature = z.infer< + typeof ApiMultisigAddRequestSignatureSchema +> + +export type AddOwnerMultisigPayload = { + address: string + newThreshold: number + signersToAdd: string[] + currentThreshold?: number +} + +export type RemoveOwnerMultisigPayload = { + address: string + newThreshold: number + signerToRemove: string +} + +export type UpdateMultisigThresholdPayload = { + newThreshold: number + address: string +} diff --git a/packages/extension/src/shared/multisig/multisig.service.ts b/packages/extension/src/shared/multisig/multisig.service.ts new file mode 100644 index 000000000..b4fd13a6e --- /dev/null +++ b/packages/extension/src/shared/multisig/multisig.service.ts @@ -0,0 +1,176 @@ +import { Call } from "starknet" +import urlJoin from "url-join" + +import { ARGENT_MULTISIG_URL } from "../api/constants" +import { Fetcher, fetcher } from "../api/fetcher" +import { Network } from "../network" +import { + networkIdToStarknetNetwork, + networkToStarknetNetwork, +} from "../utils/starknetNetwork" +import { urlWithQuery } from "../utils/url" +import { + ApiMultisigContent, + ApiMultisigDataForSigner, + ApiMultisigDataForSignerSchema, + ApiMultisigGetRequests, + ApiMultisigGetRequestsSchema, + ApiMultisigTxnResponse, +} from "./multisig.model" + +const multisigTransactionTypes = { + addSigners: "addSigners", + changeThreshold: "changeThreshold", + removeSigners: "removeSigners", + replaceSigner: "replaceSigner", +} as const +export interface IFetchMultisigDataForSigner { + signer: string + network: Network + fetcher?: Fetcher +} + +export async function fetchMultisigDataForSigner({ + signer, + network, + fetcher: fetcherImpl = fetcher, +}: IFetchMultisigDataForSigner): Promise { + if (!ARGENT_MULTISIG_URL) { + throw "Argent Multisig endpoint is not defined" + } + + const starknetNetwork = networkToStarknetNetwork(network) + + const url = urlWithQuery([ARGENT_MULTISIG_URL, starknetNetwork], { + signer, + }) + + const data = await fetcherImpl(url, { + method: "GET", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + }) + + return ApiMultisigDataForSignerSchema.parse(data) +} + +export const getMultisigTransactionType = (transactions: Call[]) => { + const entryPoints = transactions.map((tx) => tx.entrypoint) + switch (true) { + case entryPoints.includes("addSigners"): { + return multisigTransactionTypes.addSigners + } + case entryPoints.includes("changeThreshold"): { + return multisigTransactionTypes.changeThreshold + } + default: { + return undefined + } + } +} + +export interface IFetchMultisigAccountData { + address: string + networkId: string + fetcher?: Fetcher +} + +export const fetchMultisigAccountData = async ({ + address, + networkId, + fetcher: fetcherImpl = fetcher, +}: IFetchMultisigAccountData) => { + try { + if (!ARGENT_MULTISIG_URL) { + throw new Error("Multisig endpoint is not defined") + } + + const starknetNetwork = networkIdToStarknetNetwork(networkId) + + const url = urlJoin(ARGENT_MULTISIG_URL, starknetNetwork, address) + + return fetcherImpl<{ + content: ApiMultisigContent + }>(url, { + method: "GET", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + }) + } catch (e) { + throw new Error(`An error occured ${e}`) + } +} + +export const fetchMultisigRequestData = async ({ + address, + networkId, + requestId, +}: { + address: string + networkId: string + requestId: string +}) => { + try { + if (!ARGENT_MULTISIG_URL) { + throw new Error("Multisig endpoint is not defined") + } + + const starknetNetwork = networkIdToStarknetNetwork(networkId) + const url = urlJoin( + ARGENT_MULTISIG_URL, + starknetNetwork, + address, + "request", + requestId, + ) + + return fetcher(url, { + method: "GET", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + }) + } catch (e) { + throw new Error(`An error occured ${e}`) + } +} + +export const fetchMultisigRequests = async ({ + address, + networkId, +}: { + address: string + networkId: string +}): Promise => { + try { + if (!ARGENT_MULTISIG_URL) { + throw new Error("Multisig endpoint is not defined") + } + + const starknetNetwork = networkIdToStarknetNetwork(networkId) + + const url = urlJoin( + ARGENT_MULTISIG_URL, + starknetNetwork, + address, + "request", + ) + + const data = await fetcher(url, { + method: "GET", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + }) + + return ApiMultisigGetRequestsSchema.parse(data) + } catch (e) { + throw new Error(`An error occured ${e}`) + } +} diff --git a/packages/extension/src/shared/multisig/pendingTransactionsStore.ts b/packages/extension/src/shared/multisig/pendingTransactionsStore.ts new file mode 100644 index 000000000..218b3b53e --- /dev/null +++ b/packages/extension/src/shared/multisig/pendingTransactionsStore.ts @@ -0,0 +1,138 @@ +import { memoize } from "lodash-es" +import { AllowArray } from "starknet" + +import { addTransaction } from "../../background/transactions/store" +import { ArrayStorage } from "../storage" +import { SelectorFn } from "../storage/types" +import { ExtendedTransactionType } from "../transactions" +import { BaseWalletAccount } from "../wallet.model" +import { getAccountIdentifier } from "../wallet.service" +import { ApiMultisigState, ApiMultisigTransaction } from "./multisig.model" +import { getMultisigAccountFromBaseWallet } from "./utils/baseMultisig" + +export type MultisigPendingTransaction = { + requestId: string + address: string + networkId: string + timestamp: number + transaction: ApiMultisigTransaction + type?: ExtendedTransactionType + approvedSigners: string[] + nonApprovedSigners: string[] + state: ApiMultisigState + creator: string + nonce: number + transactionHash: string + notify: boolean +} +export const multisigPendingTransactionsStore = + new ArrayStorage([], { + namespace: "core:multisig:pendingTransactions", + compare: (a, b) => a.requestId === b.requestId, + }) + +export const byAccountSelector = memoize( + (account?: BaseWalletAccount) => (transaction: MultisigPendingTransaction) => + Boolean(account && transaction.address === account.address), + (account) => (account ? getAccountIdentifier(account) : "unknown-account"), +) + +export async function getMultisigPendingTransactions( + selector: SelectorFn = () => true, +): Promise { + return multisigPendingTransactionsStore.get(selector) +} + +export async function getMultisigPendingTransaction( + requestId: string, +): Promise { + const pendingTransactions = await getMultisigPendingTransactions( + (transaction) => transaction.requestId === requestId, + ) + + if (pendingTransactions.length === 0) { + return undefined + } + + return pendingTransactions[0] +} + +export async function addToMultisigPendingTransactions( + pendingTransactions: AllowArray, +): Promise { + return multisigPendingTransactionsStore.push(pendingTransactions) +} + +export async function removeFromMultisigPendingTransactions( + pendingTransactions: AllowArray, +): Promise { + return multisigPendingTransactionsStore.remove(pendingTransactions) +} + +export async function multisigPendingTransactionToTransaction( + requestId: string, + state: ApiMultisigState, +): Promise { + const pendingTxn = await getMultisigPendingTransaction(requestId) + + if (!pendingTxn) { + throw new Error("Pending Multisig transaction not found") + } + + const { transaction, type, transactionHash, networkId, address } = pendingTxn + + const multisigAccount = await getMultisigAccountFromBaseWallet({ + address, + networkId, + }) + + if (!multisigAccount) { + throw new Error("Multisig account not found") + } + + if (state === "AWAITING_SIGNATURES") { + throw new Error("Transaction is still awaiting signatures") + } + + await addTransaction( + { + hash: transactionHash, + account: multisigAccount, + meta: { + type, + transactions: transaction.calls, + }, + }, + state === "CANCELLED" ? "CANCELLED" : "RECEIVED", + ) + + await removeFromMultisigPendingTransactions(pendingTxn) +} + +export async function setHasSeenTransaction(requestId: string) { + const pendingTxn = await getMultisigPendingTransaction(requestId) + + if (!pendingTxn || !pendingTxn.notify) { + return + } + + return await multisigPendingTransactionsStore.push({ + ...pendingTxn, + notify: false, + }) +} + +export const cancelPendingMultisigTransactions = async ( + account: BaseWalletAccount, +) => { + const pendingTransactions = await getMultisigPendingTransactions( + byAccountSelector(account), + ) + + for (const pendingTransaction of pendingTransactions) { + await multisigPendingTransactionToTransaction( + pendingTransaction.requestId, + "CANCELLED", + ) + } +} diff --git a/packages/extension/src/shared/multisig/signer.ts b/packages/extension/src/shared/multisig/signer.ts new file mode 100644 index 000000000..2e5c622ac --- /dev/null +++ b/packages/extension/src/shared/multisig/signer.ts @@ -0,0 +1,44 @@ +import { + Abi, + Call, + DeployAccountSignerDetails, + InvocationsSignerDetails, + KeyPair, + Signature, + Signer, + number, +} from "starknet" + +export class MultisigSigner extends Signer { + constructor(keyPair: KeyPair) { + super(keyPair) + } + + public async signDeployAccountTransaction( + deployAccountSignerDetails: DeployAccountSignerDetails, + ): Promise { + const signatures = await super.signDeployAccountTransaction( + deployAccountSignerDetails, + ) + + const publicSigner = await this.getPubKey() + + return [number.toFelt(publicSigner), ...signatures] + } + + public async signTransaction( + transactions: Call[], + transactionsDetail: InvocationsSignerDetails, + abis?: Abi[] | undefined, + ): Promise { + const signatures = await super.signTransaction( + transactions, + transactionsDetail, + abis, + ) + + const publicSigner = await this.getPubKey() + + return [number.toFelt(publicSigner), ...signatures] + } +} diff --git a/packages/extension/src/shared/multisig/store.ts b/packages/extension/src/shared/multisig/store.ts new file mode 100644 index 000000000..8ba870d53 --- /dev/null +++ b/packages/extension/src/shared/multisig/store.ts @@ -0,0 +1,20 @@ +import { ArrayStorage } from "../storage" +import { BaseMultisigWalletAccount } from "../wallet.model" +import { accountsEqual } from "../wallet.service" +import { BasePendingMultisig, PendingMultisig } from "./types" + +export const multisigBaseWalletStore = + new ArrayStorage([], { + namespace: "core:multisig:baseWallet", + compare: accountsEqual, + }) + +export const pendingMultisigEqual = ( + a: BasePendingMultisig, + b: BasePendingMultisig, +) => a.networkId === b.networkId && a.publicKey === b.publicKey + +export const pendingMultisigStore = new ArrayStorage([], { + namespace: "core:multisig:pending", + compare: pendingMultisigEqual, +}) diff --git a/packages/extension/src/shared/multisig/tracking.ts b/packages/extension/src/shared/multisig/tracking.ts new file mode 100644 index 000000000..76bf7f029 --- /dev/null +++ b/packages/extension/src/shared/multisig/tracking.ts @@ -0,0 +1,207 @@ +import { flatMap, isEmpty, partition } from "lodash-es" +import { hash, transaction } from "starknet" + +import { + sendMultisigAccountReadyNotification, + sendMultisigTransactionNotification, +} from "../../background/notification" +import { getNetwork } from "../network" +import { networkIdToChainId } from "../utils/starknetNetwork" +import { + BaseMultisigWalletAccount, + MultisigWalletAccount, +} from "../wallet.model" +import { + fetchMultisigAccountData, + fetchMultisigDataForSigner, + fetchMultisigRequests, +} from "./multisig.service" +import { + MultisigPendingTransaction, + addToMultisigPendingTransactions, + getMultisigPendingTransactions, + multisigPendingTransactionToTransaction, +} from "./pendingTransactionsStore" +import { multisigBaseWalletStore } from "./store" +import { BasePendingMultisig } from "./types" +import { getMultisigAccounts } from "./utils/baseMultisig" +import { pendingMultisigToMultisig } from "./utils/pendingMultisig" +import { getAllPendingMultisigs } from "./utils/pendingMultisig" + +export interface MultisigTracker { + updateDataForPendingMultisig: () => Promise + updateDataForAccounts: () => Promise + updateTransactions: () => Promise +} + +export const multisigTracker: MultisigTracker = { + async updateDataForPendingMultisig() { + // get all base mutlisig accounts + const pendingMultisigs = await getAllPendingMultisigs() + + // Check with backend for any updates + const updater = async (pendingMultisig: BasePendingMultisig) => { + const network = await getNetwork(pendingMultisig.networkId) + + const { content } = await fetchMultisigDataForSigner({ + signer: pendingMultisig.publicKey, + network, + }) + + if (isEmpty(content)) { + // early return if the content is empty + return + } + + const baseMultisig: BaseMultisigWalletAccount = { + address: content[0].address, + networkId: pendingMultisig.networkId, + signers: content[0].signers, + threshold: content[0].threshold, + creator: content[0].creator, + publicKey: pendingMultisig.publicKey, + } + + sendMultisigAccountReadyNotification(baseMultisig.address) + // If the content is not empty, it means that the account is now a multisig account + return pendingMultisigToMultisig(pendingMultisig, baseMultisig) + } + await Promise.all(pendingMultisigs.map(updater)) + }, + + async updateDataForAccounts() { + // get all mutlisig accounts + const multisigAccounts = await getMultisigAccounts() + // Check with backend for any updates + const updater = async ({ + address, + networkId, + publicKey, + }: MultisigWalletAccount): Promise => { + const { content } = await fetchMultisigAccountData({ + address, + networkId, + }) + + return { + ...content, + address, + networkId, + publicKey, + } + } + + const updated = await Promise.all(multisigAccounts.map(updater)) + + // Update the accounts + await multisigBaseWalletStore.push(updated) + }, + async updateTransactions() { + // fetch all requests for full multisig accounts + const multisigs = await getMultisigAccounts() + let localPendingRequests = await getMultisigPendingTransactions() + + const fetcher = async (multisig: MultisigWalletAccount) => { + const data = await fetchMultisigRequests({ + address: multisig.address, + networkId: multisig.networkId, + }) + + return { + ...data, + address: multisig.address, + networkId: multisig.networkId, + } + } + + const allRequestsData = await Promise.all(multisigs.map(fetcher)) + + const allRequests = flatMap(allRequestsData, (a) => + a.content.map((c) => ({ + ...c, + address: a.address, + networkId: a.networkId, + })), + ) + + const [pendingRequests, fulfilledRequests] = partition( + allRequests, + (r) => r.state === "AWAITING_SIGNATURES", + ) + + // 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 localPendingRequest = localPendingRequests.find( + (r) => r.requestId === request.id, + ) + + if (localPendingRequest) { + // if the request is already in the local pending requests, update the state + return { + ...localPendingRequest, + state: request.state, + approvedSigners: request.approvedSigners, + nonApprovedSigners: request.nonApprovedSigners, + } + } + + const { version, maxFee, calls, nonce } = request.transaction + + const computedTransactionHash = hash.calculateTransactionHash( + request.address, + version, + transaction.fromCallsToExecuteCalldata(calls), + maxFee, + networkIdToChainId(request.networkId), + nonce, + ) + + // if the request is not in the local pending requests, add it + // Show notifications before adding to the store + sendMultisigTransactionNotification(computedTransactionHash) + + return { + ...request, + requestId: request.id, + timestamp: Date.now(), + transactionHash: request.transactionHash ?? computedTransactionHash, + notify: true, + } + }) + + if (updatedPendingMultisigTransactions.length > 0) { + // if there are any updated pending transactions, add them to the store + await addToMultisigPendingTransactions(updatedPendingMultisigTransactions) + } + + // Update local pending requests with fulfilled requests + 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 + + // if there are any pending transactions that are fulfilled, remove them from the multisigPendingTransactions store + // and add them to the transactions store + await Promise.all( + updatedFulfilledMultisigTransactions.map((r) => + multisigPendingTransactionToTransaction(r.requestId, r.state), + ), + ) + }, +} diff --git a/packages/extension/src/shared/multisig/types.ts b/packages/extension/src/shared/multisig/types.ts new file mode 100644 index 000000000..09b8b89e0 --- /dev/null +++ b/packages/extension/src/shared/multisig/types.ts @@ -0,0 +1,12 @@ +import { WalletAccountSigner } from "../wallet.model" + +export interface BasePendingMultisig { + networkId: string + publicKey: string +} +export interface PendingMultisig extends BasePendingMultisig { + signer: WalletAccountSigner + name: string + type: "multisig" + hidden?: boolean +} diff --git a/packages/extension/src/shared/multisig/utils/baseMultisig.ts b/packages/extension/src/shared/multisig/utils/baseMultisig.ts new file mode 100644 index 000000000..e10f909f5 --- /dev/null +++ b/packages/extension/src/shared/multisig/utils/baseMultisig.ts @@ -0,0 +1,102 @@ +import { AllowArray } from "starknet" + +import { withoutHiddenSelector } from "../../account/selectors" +import { accountService } from "../../account/service" +import { SelectorFn } from "../../storage/types" +import { + BaseMultisigWalletAccount, + BaseWalletAccount, + MultisigWalletAccount, + WalletAccount, +} from "../../wallet.model" +import { accountsEqual } from "../../wallet.service" +import { multisigBaseWalletStore } from "../store" + +export async function getBaseMultisigAccounts(): Promise< + BaseMultisigWalletAccount[] +> { + return multisigBaseWalletStore.get() +} + +export async function getMultisigAccounts( + selector: SelectorFn = withoutHiddenSelector, +): Promise { + const baseMultisigAccounts = await getBaseMultisigAccounts() + const walletAccounts = await accountService.get(selector) + + return baseMultisigAccounts + .map((baseMultisigAccount) => { + const walletAccount = walletAccounts.find((walletAccount) => + accountsEqual(walletAccount, baseMultisigAccount), + ) + + return { + ...walletAccount, + ...baseMultisigAccount, + type: "multisig", + } + }) + .filter((account): account is MultisigWalletAccount => !!account) // If the account is hidden, it will be undefined and filtered out here +} + +export async function getMultisigAccountFromBaseWallet( + baseWalletAccount: BaseWalletAccount, +): Promise { + const baseMultisigWalletAccounts = await getBaseMultisigAccounts() + const walletAccounts = await accountService.get() + + const baseMultisigAccount = baseMultisigWalletAccounts.find((acc) => + accountsEqual(acc, baseWalletAccount), + ) + + const walletAccount = walletAccounts.find((acc) => + accountsEqual(acc, baseWalletAccount), + ) + + if (!baseMultisigAccount || !walletAccount) { + return undefined + } + + return { + ...walletAccount, + ...baseMultisigAccount, + type: "multisig", + } +} + +export async function addBaseMultisigAccounts( + account: AllowArray, +): Promise { + await multisigBaseWalletStore.push(account) +} + +export async function addMultisigAccounts( + account: AllowArray, +): Promise { + await accountService.upsert(account) + await addBaseMultisigAccounts(account) +} + +export async function updateBaseMultisigAccount( + baseAccount: BaseMultisigWalletAccount, +) { + await multisigBaseWalletStore.push(baseAccount) + + return getMultisigAccountFromBaseWallet(baseAccount) +} + +export async function removeMultisigAccount( + baseAccount: BaseMultisigWalletAccount, +): Promise { + await multisigBaseWalletStore.remove((account) => + accountsEqual(account, baseAccount), + ) + + await accountService.remove(baseAccount) +} + +export async function hideMultisig( + baseAccount: BaseMultisigWalletAccount, +): Promise { + await accountService.setHide(true, baseAccount) +} diff --git a/packages/extension/src/shared/multisig/utils/pendingMultisig.ts b/packages/extension/src/shared/multisig/utils/pendingMultisig.ts new file mode 100644 index 000000000..2129bced2 --- /dev/null +++ b/packages/extension/src/shared/multisig/utils/pendingMultisig.ts @@ -0,0 +1,116 @@ +import { AllowArray } from "starknet" + +import { getNetwork } from "../../network" +import { SelectorFn } from "../../storage/types" +import { + BaseMultisigWalletAccount, + MultisigWalletAccount, +} from "../../wallet.model" +import { pendingMultisigEqual, pendingMultisigStore } from "./../store" +import { BasePendingMultisig, PendingMultisig } from "../types" +import { addMultisigAccounts } from "./baseMultisig" +import { + getPendingMultisigSelector, + withoutHiddenPendingMultisig, +} from "./selectors" + +export async function getAllPendingMultisigs( + selector: SelectorFn = withoutHiddenPendingMultisig, +): Promise { + return pendingMultisigStore.get(selector) +} + +export async function getPendingMultisig( + basePendingMultisig: BasePendingMultisig, +) { + const pendingMultisigs = await getAllPendingMultisigs() + return pendingMultisigs.find((pendingMultisig) => + pendingMultisigEqual(pendingMultisig, basePendingMultisig), + ) +} + +export async function addPendingMultisig( + pendingMultisig: AllowArray, +): Promise { + return pendingMultisigStore.push(pendingMultisig) +} + +export async function removePendingMultisig( + basePendingMultisig: BasePendingMultisig, +): Promise { + const pendingMultisig = await getPendingMultisig(basePendingMultisig) + + if (!pendingMultisig) { + throw new Error("Pending multisig to remove not found") + } + + return pendingMultisigStore.remove(pendingMultisig) +} + +export async function pendingMultisigToMultisig( + basePendingMultisig: BasePendingMultisig, + multisigData: BaseMultisigWalletAccount, +) { + const network = await getNetwork(multisigData.networkId) + + const pendingMultisig = await getPendingMultisig(basePendingMultisig) + + if (!pendingMultisig) { + throw new Error("Pending multisig to convert to Multisig not found") + } + + const fullMultisig: MultisigWalletAccount = { + address: multisigData.address, + name: pendingMultisig.name, + type: "multisig", + networkId: pendingMultisig.networkId, + signer: pendingMultisig.signer, + signers: multisigData.signers, + publicKey: pendingMultisig.publicKey, + threshold: multisigData.threshold, + creator: multisigData.creator, + network, + needsDeploy: false, + hidden: false, + } + + await removePendingMultisig(pendingMultisig) + await addMultisigAccounts(fullMultisig) + return fullMultisig +} + +export async function updatePendingMultisigName( + base: BasePendingMultisig, + name: string, +) { + const [hit] = await getAllPendingMultisigs(getPendingMultisigSelector(base)) + if (!hit) { + return + } + await pendingMultisigStore.push({ + ...hit, + name, + }) +} + +export async function hidePendingMultisig(base: BasePendingMultisig) { + const [hit] = await getAllPendingMultisigs(getPendingMultisigSelector(base)) + if (!hit) { + return + } + await pendingMultisigStore.push({ + ...hit, + hidden: true, + }) +} + +export async function unhidePendingMultisig(base: BasePendingMultisig) { + const [hit] = await getAllPendingMultisigs(getPendingMultisigSelector(base)) + if (!hit) { + return + } + await pendingMultisigStore.push({ + ...hit, + hidden: false, + }) +} diff --git a/packages/extension/src/shared/multisig/utils/selectors.ts b/packages/extension/src/shared/multisig/utils/selectors.ts new file mode 100644 index 000000000..6254d8e7a --- /dev/null +++ b/packages/extension/src/shared/multisig/utils/selectors.ts @@ -0,0 +1,18 @@ +import { memoize } from "lodash-es" + +import { pendingMultisigEqual } from "../store" +import { BasePendingMultisig, PendingMultisig } from "../types" + +export const getPendingMultisigSelector = memoize( + (base: BasePendingMultisig) => (multisig: PendingMultisig) => + pendingMultisigEqual(multisig, base), +) + +export const withoutHiddenPendingMultisig = ( + pendingMultisig: PendingMultisig, +) => !pendingMultisig.hidden + +export const withHiddenPendingMultisig = memoize( + () => true, + () => "default", +) diff --git a/packages/extension/src/shared/network/defaults.ts b/packages/extension/src/shared/network/defaults.ts index a1afc1677..b05496844 100644 --- a/packages/extension/src/shared/network/defaults.ts +++ b/packages/extension/src/shared/network/defaults.ts @@ -1,4 +1,4 @@ -import { Network } from "./type" +import type { Network } from "./type" const DEV_ONLY_NETWORKS: Network[] = [ { @@ -7,11 +7,12 @@ const DEV_ONLY_NETWORKS: Network[] = [ chainId: "SN_GOERLI", baseUrl: "https://external.integration.starknet.io", accountClassHash: { - argentAccount: + standard: "0x033434ad846cdd5f23eb73ff09fe6fddd568284a0fb7d1be20ee482f044dabe2", }, multicallAddress: "0x05754af3760f3356da99aea5c3ec39ccac7783d925a19666ebbeca58ff0087f4", + status: "unknown", }, ] @@ -23,11 +24,12 @@ export const defaultNetworks: Network[] = [ baseUrl: "https://alpha-mainnet.starknet.io", explorerUrl: "https://voyager.online", accountClassHash: { - argentAccount: + standard: "0x033434ad846cdd5f23eb73ff09fe6fddd568284a0fb7d1be20ee482f044dabe2", }, multicallAddress: "0x05754af3760f3356da99aea5c3ec39ccac7783d925a19666ebbeca58ff0087f4", + status: "unknown", readonly: true, }, { @@ -37,17 +39,20 @@ export const defaultNetworks: Network[] = [ baseUrl: "https://alpha4.starknet.io", explorerUrl: "https://goerli.voyager.online", accountClassHash: { - argentAccount: + standard: "0x033434ad846cdd5f23eb73ff09fe6fddd568284a0fb7d1be20ee482f044dabe2", - argentPluginAccount: + plugin: "0x4ee23ad83fb55c1e3fac26e2cd951c60abf3ddc851caa9a7fbb9f5eddb2091", - argentBetterMulticallAccount: + betterMulticall: "0x057c2f22f0209a819e6c60f78ad7d3690f82ade9c0c68caea492151698934ede", argent5MinuteEscapeTestingAccount: "0x058a42e2553e65e301b7f22fb89e4a2576e9867c1e20bb1d32746c74ff823639", + multisig: + "0x04ba0f956a26b5e0d7e491661a0c56a6eb0fc25d49912677de09439673c3c828", }, multicallAddress: "0x05754af3760f3356da99aea5c3ec39ccac7783d925a19666ebbeca58ff0087f4", + status: "unknown", readonly: true, }, { @@ -57,17 +62,20 @@ export const defaultNetworks: Network[] = [ baseUrl: "https://alpha4-2.starknet.io", explorerUrl: "https://goerli-2.voyager.online/", accountClassHash: { - argentAccount: + standard: "0x033434ad846cdd5f23eb73ff09fe6fddd568284a0fb7d1be20ee482f044dabe2", - argentPluginAccount: + plugin: "0x4ee23ad83fb55c1e3fac26e2cd951c60abf3ddc851caa9a7fbb9f5eddb2091", - argentBetterMulticallAccount: + multisig: + "0x04ba0f956a26b5e0d7e491661a0c56a6eb0fc25d49912677de09439673c3c828", + betterMulticall: "0x057c2f22f0209a819e6c60f78ad7d3690f82ade9c0c68caea492151698934ede", argent5MinuteEscapeTestingAccount: "0x058a42e2553e65e301b7f22fb89e4a2576e9867c1e20bb1d32746c74ff823639", }, multicallAddress: "0x05754af3760f3356da99aea5c3ec39ccac7783d925a19666ebbeca58ff0087f4", + status: "unknown", }, ...(process.env.NODE_ENV === "development" ? DEV_ONLY_NETWORKS : []), { @@ -75,8 +83,9 @@ export const defaultNetworks: Network[] = [ chainId: "SN_GOERLI", baseUrl: "http://localhost:5050", explorerUrl: "https://devnet.starkscan.co", + status: "ok", name: "Localhost 5050", }, ] -export const defaultNetwork = defaultNetworks[1] +export const defaultNetwork = defaultNetworks[0] // default to mainnet. Previously was testnet. diff --git a/packages/extension/src/shared/network/index.ts b/packages/extension/src/shared/network/index.ts index 6f77032f7..e176e7cba 100644 --- a/packages/extension/src/shared/network/index.ts +++ b/packages/extension/src/shared/network/index.ts @@ -1,11 +1,10 @@ import { mergeArrayStableWith } from "../storage/array" import { SelectorFn } from "../storage/types" -import { assertSchema } from "../utils/schema" +import { defaultNetworks } from "./defaults" import { networkSchema } from "./schema" import { networkSelector, networkSelectorByChainId } from "./selectors" import { - customNetworksStore, - defaultCustomNetworks, + allNetworksStore, defaultReadonlyNetworks, equalNetwork, } from "./storage" @@ -22,8 +21,8 @@ export function extendByDefaultReadonlyNetworks(customNetworks: Network[]) { export async function getNetworks( selector?: SelectorFn, ): Promise { - const customNetworks = await customNetworksStore.get() - const allNetworks = extendByDefaultReadonlyNetworks(customNetworks) + const storedNetworks = await allNetworksStore.get() + const allNetworks = extendByDefaultReadonlyNetworks(storedNetworks) if (selector) { return allNetworks.filter(selector) } @@ -41,22 +40,20 @@ export async function getNetworkByChainId(chainId: string) { } export const addNetwork = async (network: Network) => { - await assertSchema(networkSchema, network) - return customNetworksStore.push(network) + networkSchema.parse(network) + await allNetworksStore.push(network) } export const removeNetwork = async (networkId: string) => { - return customNetworksStore.remove(networkSelector(networkId)) + return allNetworksStore.remove(networkSelector(networkId)) } export const restoreDefaultCustomNetworks = async () => { - const customNetworks = await customNetworksStore.get() - await customNetworksStore.remove(customNetworks) - await customNetworksStore.push(defaultCustomNetworks) + await allNetworksStore.remove((network) => !!network.id) + await allNetworksStore.push(defaultNetworks) } export type { Network, NetworkStatus } from "./type" -export { customNetworksStore } from "./storage" export { networkSchema } from "./schema" export { getProvider } from "./provider" export { defaultNetworks, defaultNetwork } from "./defaults" diff --git a/packages/extension/src/shared/network/provider.ts b/packages/extension/src/shared/network/provider.ts index d87fffd61..736066daa 100644 --- a/packages/extension/src/shared/network/provider.ts +++ b/packages/extension/src/shared/network/provider.ts @@ -1,6 +1,7 @@ import { memoize } from "lodash-es" import { SequencerProvider } from "starknet" import { SequencerProvider as SequencerProviderv4 } from "starknet4" +import { SequencerProvider as SequencerProviderv5 } from "starknet5" import { Network } from "./type" @@ -19,3 +20,11 @@ const getProviderV4ForBaseUrl = memoize((baseUrl: string) => { export function getProviderv4(network: Network) { return getProviderV4ForBaseUrl(network.baseUrl) } + +const getProviderV5ForBaseUrl = memoize((baseUrl: string) => { + return new SequencerProviderv5({ baseUrl }) +}) + +export function getProviderv5(network: Network) { + return getProviderV5ForBaseUrl(network.baseUrl) +} diff --git a/packages/extension/src/shared/network/schema.ts b/packages/extension/src/shared/network/schema.ts index 700bdbeb5..6043e84a6 100644 --- a/packages/extension/src/shared/network/schema.ts +++ b/packages/extension/src/shared/network/schema.ts @@ -1,49 +1,84 @@ -import { Schema, boolean, object, string } from "yup" - -import { Network } from "./type" +import { z } from "zod" const REGEX_HEXSTRING = /^0x[a-f0-9]+$/i const REGEX_URL_WITH_LOCAL = /^(https?:\/\/)(?:\S+(?::\S*)?@)?(?:(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)(?:\.(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)*\.?)(?::\d{2,5})?(?:[/?#]\S*)?$/ -export const networkSchema: Schema = object() - .required() - .shape({ - id: string().required().min(2).max(31), - name: string().required().min(2).max(128), - chainId: string() - .required() - .min(2) - .max(31) // max 31 characters as required by starknet short strings - .matches(/^[a-zA-Z0-9_]+$/, { - message: - "${path} must be hexadecimal string, uppercase alphanumeric or underscore, like 'SN_GOERLI'", - }), - baseUrl: string() - .required() - .matches(REGEX_URL_WITH_LOCAL, "${path} must be a valid URL"), - accountImplementation: string().optional().matches(REGEX_HEXSTRING), - accountClassHash: object({ - argentAccount: string() - .label("Account class hash") - .required() - .matches(REGEX_HEXSTRING), - argentPluginAccount: string() - .label("Plugin account class hash") - .optional() - .matches(REGEX_HEXSTRING), - }).default( - undefined, - ) /** default(undefined) for an optional object with required children {@see https://github.com/jquense/yup/issues/772#issuecomment-743270211} */, - explorerUrl: string() - .optional() - .matches(REGEX_URL_WITH_LOCAL, "${path} must be a valid URL"), - blockExplorerUrl: string() - .optional() - .matches(REGEX_URL_WITH_LOCAL, "${path} must be a valid URL"), - rpcUrl: string() - .optional() - .matches(REGEX_URL_WITH_LOCAL, "${path} must be a valid URL"), - multicallAddress: string().optional().matches(REGEX_HEXSTRING), - readonly: boolean().optional(), - }) +export const networkStatusSchema = z.enum([ + "ok", + "degraded", + "error", + "unknown", +]) +export const networkSchema = z.object({ + id: z.string().min(2).max(31), + name: z.string().min(2).max(128), + chainId: z + .string() + .min(2) + .max(31) // max 31 characters as required by starknet short strings + .regex(/^[a-zA-Z0-9_]+$/, { + message: + "chain id must be hexadecimal string, uppercase alphanumeric or underscore, like 'SN_GOERLI'", + }), + baseUrl: z + .string() + .regex(REGEX_URL_WITH_LOCAL, "base url must be a valid URL"), + + accountImplementation: z.optional( + z.string().regex(REGEX_HEXSTRING, { + message: `Account class hash must match the following: /^0x[a-f0-9]+$/i`, + }), + ), + accountClassHash: z.union([ + z.object({ + standard: z + .string() + .regex(REGEX_HEXSTRING, { + message: `Account class hash must match the following: /^0x[a-f0-9]+$/i`, + }) + .optional(), + plugin: z + .string() + .regex(REGEX_HEXSTRING, { + message: `Account class hash must match the following: /^0x[a-f0-9]+$/i`, + }) + .optional(), + multisig: z + .string() + .regex(REGEX_HEXSTRING, { + message: `Account class hash must match the following: /^0x[a-f0-9]+$/i`, + }) + .optional(), + betterMulticall: z + .string() + .regex(REGEX_HEXSTRING, { + message: `Account class hash must match the following: /^0x[a-f0-9]+$/i`, + }) + .optional(), + argent5MinuteEscapeTestingAccount: z + .string() + .regex(REGEX_HEXSTRING, { + message: `Account class hash must match the following: /^0x[a-f0-9]+$/i`, + }) + .optional(), + }), + z.undefined(), + ]), + explorerUrl: z.optional( + z.string().regex(REGEX_URL_WITH_LOCAL, "explorer url must be a valid URL"), + ), + blockExplorerUrl: z.optional( + z + .string() + .regex(REGEX_URL_WITH_LOCAL, "block explorer url must be a valid URL"), + ), + rpcUrl: z.optional( + z.string().regex(REGEX_URL_WITH_LOCAL, "rpc url must be a valid URL"), + ), + multicallAddress: z.optional( + z.string().regex(REGEX_HEXSTRING, "multicall address must be a valid URL"), + ), + readonly: z.optional(z.boolean()), + status: networkStatusSchema, +}) diff --git a/packages/extension/src/shared/network/service/implementation.ts b/packages/extension/src/shared/network/service/implementation.ts new file mode 100644 index 000000000..7f7aa0512 --- /dev/null +++ b/packages/extension/src/shared/network/service/implementation.ts @@ -0,0 +1,11 @@ +import { messageClient } from "../../../ui/services/messaging/trpc" +import { Network } from "../type" +import type { INetworkService } from "./interface" + +export class NetworkService implements INetworkService { + constructor(private readonly trpcClient: typeof messageClient) {} + + async add(network: Network): Promise { + return this.trpcClient.network.add.mutate(network) + } +} diff --git a/packages/extension/src/shared/network/service/interface.ts b/packages/extension/src/shared/network/service/interface.ts new file mode 100644 index 000000000..2420a03ff --- /dev/null +++ b/packages/extension/src/shared/network/service/interface.ts @@ -0,0 +1,5 @@ +import { Network } from "../type" + +export interface INetworkService { + add(network: Network): Promise +} diff --git a/packages/extension/src/shared/network/storage.ts b/packages/extension/src/shared/network/storage.ts index 7321dcf34..d1d5a346d 100644 --- a/packages/extension/src/shared/network/storage.ts +++ b/packages/extension/src/shared/network/storage.ts @@ -12,10 +12,7 @@ export const defaultReadonlyNetworks = defaultNetworks.filter( ({ readonly }) => !!readonly, ) -export const customNetworksStore = new ArrayStorage( - defaultCustomNetworks, - { - namespace: "core:customNetworks", - compare: equalNetwork, - }, -) +export const allNetworksStore = new ArrayStorage(defaultNetworks, { + namespace: "core:allNetworks", + compare: equalNetwork, +}) diff --git a/packages/extension/src/shared/network/type.ts b/packages/extension/src/shared/network/type.ts index b7c784046..374c4fc3f 100644 --- a/packages/extension/src/shared/network/type.ts +++ b/packages/extension/src/shared/network/type.ts @@ -1,21 +1,7 @@ -export interface Network { - id: string - name: string - chainId: string - baseUrl: string - /** URL of the block explorer API service */ - explorerUrl?: string - /** URL of the user-facing block explorer web interface */ - blockExplorerUrl?: string - accountClassHash?: { - argentAccount: string - argentPluginAccount?: string - argentBetterMulticallAccount?: string - argent5MinuteEscapeTestingAccount?: string - } - rpcUrl?: string - readonly?: boolean - multicallAddress?: string -} +import { z } from "zod" -export type NetworkStatus = "ok" | "degraded" | "error" | "unknown" +import { networkSchema, networkStatusSchema } from "./schema" + +export type Network = z.infer + +export type NetworkStatus = z.infer diff --git a/packages/extension/src/shared/network/utils.ts b/packages/extension/src/shared/network/utils.ts index 3ff4120b6..b3213eac0 100644 --- a/packages/extension/src/shared/network/utils.ts +++ b/packages/extension/src/shared/network/utils.ts @@ -4,41 +4,43 @@ import { isEqualAddress } from "../../ui/services/addresses" import { ArgentAccountType } from "../wallet.model" import { Network } from "./type" -export function mapArgentAccountTypeToImplementationKey( - type: ArgentAccountType, -): keyof Required["accountClassHash"] { - switch (type) { - case "argent-plugin": - return "argentPluginAccount" - case "argent-better-multicall": - return "argentBetterMulticallAccount" - case "argent": - default: - return "argentAccount" - } -} - export function mapImplementationToArgentAccountType( implementation: string, network: Network, ): ArgentAccountType { - if ( - isEqualAddress( - implementation, - network.accountClassHash?.argentPluginAccount, - ) - ) { - return "argent-plugin" + if (isEqualAddress(implementation, network.accountClassHash?.plugin)) { + return "plugin" + } + + if (isEqualAddress(implementation, network.accountClassHash?.multisig)) { + return "multisig" } + if ( - isEqualAddress( - implementation, - network.accountClassHash?.argentBetterMulticallAccount, - ) + isEqualAddress(implementation, network.accountClassHash?.betterMulticall) ) { - return "argent-better-multicall" + return "betterMulticall" + } + + return "standard" +} + +export function getChainIdFromNetworkId( + networkId: string, +): constants.StarknetChainId { + switch (networkId) { + case "mainnet-alpha": + return constants.StarknetChainId.SN_MAIN + + case "goerli-alpha": + return constants.StarknetChainId.SN_GOERLI + + case "goerli-alpha-2": + return constants.StarknetChainId.SN_GOERLI2 + + default: + throw new Error(`Unknown networkId: ${networkId}`) } - return "argent" } export function getChainIdFromNetworkId( diff --git a/packages/extension/src/shared/network/view/index.ts b/packages/extension/src/shared/network/view/index.ts new file mode 100644 index 000000000..7e203b0a9 --- /dev/null +++ b/packages/extension/src/shared/network/view/index.ts @@ -0,0 +1,4 @@ +import { atomFromRepo } from "../../../ui/views/implementation/atomFromRepo" +import { networksRepository } from "../../storage/__new/repositories/network" + +export const networksView = atomFromRepo(networksRepository) diff --git a/packages/extension/src/shared/preAuthorizations.ts b/packages/extension/src/shared/preAuthorizations.ts index e45d605c2..a71121278 100644 --- a/packages/extension/src/shared/preAuthorizations.ts +++ b/packages/extension/src/shared/preAuthorizations.ts @@ -1,7 +1,8 @@ import { isArray, pick } from "lodash-es" import browser from "webextension-polyfill" -import { accountStore } from "./account/store" +import { withHiddenSelector } from "./account/selectors" +import { accountService } from "./account/service" import { ArrayStorage } from "./storage" import { useArrayStorage } from "./storage/hooks" import { BaseWalletAccount } from "./wallet.model" @@ -35,7 +36,7 @@ export const migratePreAuthorizations = async () => { const old = await getFromStorage("PREAUTHORIZATION:APPROVED") if (isArray(old) && old.length > 0) { await browser.storage.local.remove("PREAUTHORIZATION:APPROVED") - const allAccounts = await accountStore.get() + const allAccounts = await accountService.get(withHiddenSelector) const accountHostCombinations = old.flatMap((h) => allAccounts.map((a) => ({ diff --git a/packages/extension/src/shared/schemas/address.ts b/packages/extension/src/shared/schemas/address.ts new file mode 100644 index 000000000..9a57a3b6e --- /dev/null +++ b/packages/extension/src/shared/schemas/address.ts @@ -0,0 +1,26 @@ +import { validateChecksumAddress } from "starknet5" +import { z } from "zod" + +import { Hex } from "./hex" + +type Address = Hex + +export const addressSchemaBase = z + .string() + .regex(/^0x[0-9a-fA-F]+$/, "Invalid address") + .min(50, "Addresses must at least be 50 characters long") + .max(66, "Addresses must at most be 66 characters long") + +export const addressSchema = addressSchemaBase + .refine((value) => { + // if contains capital letters, make sure to check checksum + return value.toLowerCase() === value || validateChecksumAddress(value) + }, "Invalid address (checksum error)") + .transform
((value) => { + // remove 0x prefix + const withoutPrefix = value.startsWith("0x") ? value.slice(2) : value + // pad left until length is 64 + const padded = withoutPrefix.padStart(64, "0") + // add 0x prefix + return `0x${padded}` + }) diff --git a/packages/extension/src/shared/schemas/hex.ts b/packages/extension/src/shared/schemas/hex.ts new file mode 100644 index 000000000..a0fc82faa --- /dev/null +++ b/packages/extension/src/shared/schemas/hex.ts @@ -0,0 +1,17 @@ +import { z } from "zod" + +export type Hex = `0x${string}` + +export const hexSchemaBase = z + .string() + .regex(/^(0x)?[0-9a-fA-F]+$/, "Invalid hex string") + +export const hexSchema = hexSchemaBase.transform((value) => { + // remove 0x prefix + const withoutPrefix = value.startsWith("0x") ? value.slice(2) : value + // pad left until length is even + const padded = + withoutPrefix.length % 2 === 0 ? withoutPrefix : `0${withoutPrefix}` + // add 0x prefix + return `0x${padded}` +}) diff --git a/packages/extension/src/shared/schemas/seedphrase.ts b/packages/extension/src/shared/schemas/seedphrase.ts new file mode 100644 index 000000000..86ee2e586 --- /dev/null +++ b/packages/extension/src/shared/schemas/seedphrase.ts @@ -0,0 +1,7 @@ +import { validateMnemonic } from "@scure/bip39" +import { wordlist as en } from "@scure/bip39/wordlists/english" +import { z } from "zod" + +export const seedphraseSchema = z.string().refine((value) => { + return validateMnemonic(value, en) // we only support english for now +}, "Invalid seedphrase") diff --git a/packages/extension/src/shared/shield/jwtFetcher.ts b/packages/extension/src/shared/shield/jwtFetcher.ts index 6b490c593..2ad8d253b 100644 --- a/packages/extension/src/shared/shield/jwtFetcher.ts +++ b/packages/extension/src/shared/shield/jwtFetcher.ts @@ -20,7 +20,7 @@ export const jwtFetcher = async ( } const fetcher = fetcherWithArgentApiHeaders() try { - return fetcher(input, initWithArgentJwtHeaders) + return await fetcher(input, initWithArgentJwtHeaders) } catch (error) { IS_DEV && console.warn(coerceErrorToString(error)) throw error diff --git a/packages/extension/src/shared/storage/__new/__test__/inmemoryImplementations.test.ts b/packages/extension/src/shared/storage/__new/__test__/inmemoryImplementations.test.ts new file mode 100644 index 000000000..80baa5260 --- /dev/null +++ b/packages/extension/src/shared/storage/__new/__test__/inmemoryImplementations.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, test } from "vitest" + +import { + InMemoryObjectStore, + InMemoryRepository, +} from "./inmemoryImplementations" + +describe("InMemoryObjectStore", () => { + const store = new InMemoryObjectStore<{ + key: string + }>({ namespace: "test" }) + + test("get method returns default data", async () => { + const data = await store.get() + expect(data).toEqual({}) + }) + + test("set method updates data", async () => { + await store.set({ key: "value" }) + const data = await store.get() + expect(data).toEqual({ key: "value" }) + }) + + test("subscribe method listens to changes", async () => { + let change + const unsubscribe = store.subscribe((storageChange) => { + change = storageChange + }) + + await store.set({ key: "newValue" }) + + expect(change).toEqual({ + oldValue: { key: "value" }, + newValue: { key: "newValue" }, + }) + + unsubscribe() + }) +}) + +describe("InMemoryRepository", () => { + const repo = new InMemoryRepository<{ + id: number + value: string + }>({ + namespace: "test", + compare(a, b) { + return a.id === b.id + }, + }) + + test("get method returns default data", async () => { + const data = await repo.get() + expect(data).toEqual([]) + }) + + test("upsert method creates and updates items", async () => { + const upsertResult1 = await repo.upsert({ id: 1, value: "a" }) + expect(upsertResult1).toEqual({ created: 1, updated: 0 }) + + const upsertResult2 = await repo.upsert({ id: 1, value: "b" }) + expect(upsertResult2).toEqual({ created: 0, updated: 1 }) + + const data = await repo.get() + expect(data).toEqual([{ id: 1, value: "b" }]) + }) + + test("remove method removes items", async () => { + const removedItems = await repo.remove({ id: 1, value: "b" }) + expect(removedItems).toEqual([{ id: 1, value: "b" }]) + }) + + test("subscribe method listens to changes", async () => { + const data = await repo.get() + expect(data).toEqual([]) + + let change + const unsubscribe = repo.subscribe((storageChange) => { + change = storageChange + }) + + await repo.upsert({ id: 2, value: "c" }) + + expect(change).toEqual({ + oldValue: [], + newValue: [{ id: 2, value: "c" }], + }) + + unsubscribe() + }) +}) diff --git a/packages/extension/src/shared/storage/__new/__test__/inmemoryImplementations.ts b/packages/extension/src/shared/storage/__new/__test__/inmemoryImplementations.ts new file mode 100644 index 000000000..618b3a627 --- /dev/null +++ b/packages/extension/src/shared/storage/__new/__test__/inmemoryImplementations.ts @@ -0,0 +1,147 @@ +import { isArray, isEqual, isFunction } from "lodash-es" + +import { + AllowArray, + AllowPromise, + IObjectStore, + IObjectStoreOptions, + IRepository, + IRepositoryOptions, + SelectorFn, + SetterFn, + StorageChange, + UpsertResult, +} from "../interface" + +export class InMemoryObjectStore implements IObjectStore { + public namespace: string + + private _data: T + + private _merge: Required>["merge"] + + private _subscribers: Set<(value: StorageChange) => AllowPromise> = + new Set() + + constructor(options: IObjectStoreOptions) { + this.namespace = options.namespace + this._data = options.defaults ? { ...options.defaults } : ({} as T) + this._merge = + options.merge || ((oldValue, newValue) => ({ ...oldValue, ...newValue })) + + if (options.deserialize || options.serialize) { + throw new Error("Serialization is not supported in InMemoryObjectStore") + } + } + + async get(): Promise { + return this._data + } + + async set(value: T): Promise { + const oldValue = this._data + this._data = this._merge(oldValue, value) + const change: StorageChange = { oldValue, newValue: this._data } + this._subscribers.forEach((subscriber) => { + void subscriber(change) + }) + } + + subscribe( + callback: (value: StorageChange) => AllowPromise, + ): () => void { + this._subscribers.add(callback) + return () => { + this._subscribers.delete(callback) + } + } +} + +export class InMemoryRepository implements IRepository { + public namespace: string + + private _data: T[] + + private _compare: Required>["compare"] + + private _subscribers: Set< + (changeSet: StorageChange) => AllowPromise + > = new Set() + + constructor(public readonly options: IRepositoryOptions) { + this.namespace = options.namespace + this._data = options.defaults ? [...options.defaults] : [] + this._compare = options.compare ?? isEqual + } + + async get(selector?: SelectorFn): Promise { + return selector ? this._data.filter(selector) : this._data + } + + async upsert(value: AllowArray | SetterFn): Promise { + const oldValue = [...this._data] + const items = isFunction(value) + ? value(oldValue) + : isArray(value) + ? value + : [value] + + let created = 0 + let updated = 0 + + for (const item of items) { + const index = this._data.findIndex((existing) => + this._compare(existing, item), + ) + + if (index >= 0) { + this._data[index] = item + updated++ + } else { + this._data.push(item) + created++ + } + } + + const change: StorageChange = { oldValue, newValue: this._data } + this._subscribers.forEach((subscriber) => { + void subscriber(change) + }) + + return { created, updated } + } + + async remove(value: AllowArray | SelectorFn): Promise { + const oldValue = [...this._data] + const selector: SelectorFn = isFunction(value) + ? value + : isArray(value) + ? (item) => value.some((v) => this._compare(v, item)) + : (item) => this._compare(value, item) + const removed: T[] = [] + + this._data = this._data.filter((item) => { + if (selector(item)) { + removed.push(item) + return false + } + return true + }) + + const change: StorageChange = { oldValue, newValue: this._data } + this._subscribers.forEach((subscriber) => { + void subscriber(change) + }) + + return removed + } + + subscribe( + callback: (changeSet: StorageChange) => AllowPromise, + ): () => void { + this._subscribers.add(callback) + return () => { + this._subscribers.delete(callback) + } + } +} diff --git a/packages/extension/src/shared/storage/__new/__test__/keyvalue.test.ts b/packages/extension/src/shared/storage/__new/__test__/keyvalue.test.ts new file mode 100644 index 000000000..ad7701039 --- /dev/null +++ b/packages/extension/src/shared/storage/__new/__test__/keyvalue.test.ts @@ -0,0 +1,61 @@ +import { KeyValueStorage } from "../../keyvalue" +import { IObjectStore } from "../interface" +import { adaptKeyValue } from "../keyvalue" + +type TestData = { + foo: string | null + bar: number +} + +describe("adaptKeyValueStore", () => { + let store: KeyValueStorage + let adaptedStore: IObjectStore + + beforeAll(() => { + store = new KeyValueStorage( + { foo: null, bar: 2 }, + { namespace: "testAdapt", areaName: "local" }, + ) + adaptedStore = adaptKeyValue(store) + }) + + it("should get data from the store", async () => { + const result = await adaptedStore.get() + expect(result).toEqual({ foo: null, bar: 2 }) + }) + + it("should set data to the store", async () => { + await adaptedStore.set({ foo: "baz", bar: 3 }) + const barResult = await store.get("bar") + const fooResult = await store.get("foo") + expect(barResult).toEqual(3) + expect(fooResult).toEqual("baz") + }) + + it("allows data to be set to null", async () => { + await adaptedStore.set({ foo: null, bar: 3 }) + const barResult = await store.get("bar") + const fooResult = await store.get("foo") + + expect(barResult).toEqual(3) + expect(fooResult).toEqual(null) + }) + + it("should subscribe to the store", async () => { + const callback = vi.fn() + adaptedStore.subscribe(callback) + + await adaptedStore.set({ foo: "bar" }) + + expect(callback).toHaveBeenCalledWith({ foo: "bar", bar: 3 }) + }) + + it("should batch multiple changes into one callback", async () => { + const callback = vi.fn() + adaptedStore.subscribe(callback) + + await adaptedStore.set({ foo: "baz", bar: 4 }) + + expect(callback).toHaveBeenCalledWith({ foo: "baz", bar: 4 }) + }) +}) diff --git a/packages/extension/src/shared/storage/__new/__test__/mockFunctionImplementation.ts b/packages/extension/src/shared/storage/__new/__test__/mockFunctionImplementation.ts new file mode 100644 index 000000000..9def2c83d --- /dev/null +++ b/packages/extension/src/shared/storage/__new/__test__/mockFunctionImplementation.ts @@ -0,0 +1,18 @@ +import { vi } from "vitest" + +import { IObjectStore, IRepository } from "../interface" + +export class MockFnObjectStore implements IObjectStore { + public namespace = "test:mockFnObjectStore" + get = vi.fn() + set = vi.fn() + subscribe = vi.fn() +} + +export class MockFnRepository implements IRepository { + public namespace = "test:mockFnRepository" + get = vi.fn() + upsert = vi.fn() + subscribe = vi.fn() + remove = vi.fn() +} diff --git a/packages/extension/src/shared/storage/__new/interface.ts b/packages/extension/src/shared/storage/__new/interface.ts new file mode 100644 index 000000000..f9f02805c --- /dev/null +++ b/packages/extension/src/shared/storage/__new/interface.ts @@ -0,0 +1,106 @@ +/** + * Represents a value of type T or a Promise of type T. + */ +export type AllowPromise = T | Promise + +/** + * Represents a value of type T or an array of type T. + */ +export type AllowArray = T | T[] + +/** + * A function that takes a value of type T and returns a boolean. + */ +export type SelectorFn = (value: T) => boolean + +/** + * A function that takes an array of values of type T and returns an array of values of type T. + */ +export type SetterFn = (value: T[]) => T[] + +/** + * Represents a change in storage, including the old and new values. + */ +export interface StorageChange { + /** Optional. The new value of the item, if there is a new value. */ + newValue?: T + /** Optional. The old value of the item, if there was an old value. */ + oldValue?: T +} + +/** + * Represents options for creating a new repository. + */ +export interface IRepositoryOptions { + /** The namespace for the repository. */ + namespace: string + /** Optional. The default values for the repository. */ + defaults?: T[] + /** Optional. A function that serializes a value of type T. */ + serialize?: (value: T[]) => any + /** Optional. A function that deserializes a value to type T. */ + deserialize?: (value: any) => AllowPromise + /** Optional. A function that compares two values of type T and returns a boolean. */ + compare?: (a: T, b: T) => boolean +} + +export type UpsertResult = { created: number; updated: number } + +/** + * Represents a repository for managing data of type T. + */ +export interface IRepository { + /** The namespace for the repository. */ + namespace: string + + /** + * Retrieves items from the repository based on the provided selector function. + * @param selector - Optional. A function that filters the items to be retrieved. + * @returns A Promise that resolves to an array of items of type T. + */ + get(selector?: SelectorFn): Promise + + /** + * Inserts or updates items in the repository. + * @param value - An array of items, a single item, or a setter function that operates on an array of items. + * @returns A Promise that resolves to a boolean indicating whether the operation succeeded. + */ + upsert(value: AllowArray | SetterFn): Promise + + /** + * Removes items from the repository based on the provided value or selector function. + * @param value - An array of items, a single item, or a selector function that filters the items to be removed. + * @returns A Promise that resolves to an array of removed items of type T. + */ + remove(value: AllowArray | SelectorFn): Promise + + /** + * Subscribes to changes in the repository. + * @param callback - A function that gets called when there are changes in the repository. + * @returns A function that can be called to unsubscribe from the changes. + */ + subscribe( + callback: (changeSet: StorageChange) => AllowPromise, + ): () => void +} + +export interface IObjectStoreOptions { + namespace: string + /** Optional. The default values for the repository. */ + defaults?: T + /** Optional. A function that serializes a value of type T. */ + serialize?: (value: T) => any + /** Optional. A function that deserializes a value to type T. */ + deserialize?: (value: any) => AllowPromise + /** Optional. A function that merges two values of type T. */ + merge?: (oldValue: T, newValue: Partial) => T +} + +export interface IObjectStore { + namespace: string + get(): Promise + set(value: Partial): Promise + subscribe( + callback: (value: StorageChange>) => AllowPromise, + ): () => void +} diff --git a/packages/extension/src/shared/storage/__new/keyvalue.ts b/packages/extension/src/shared/storage/__new/keyvalue.ts new file mode 100644 index 000000000..e0478c81a --- /dev/null +++ b/packages/extension/src/shared/storage/__new/keyvalue.ts @@ -0,0 +1,64 @@ +import { debounce, noop, union } from "lodash-es" + +import { KeyValueStorage } from "../keyvalue" +import { IObjectStore, StorageChange } from "./interface" + +export function adaptKeyValue>( + storage: KeyValueStorage, +): IObjectStore { + return { + namespace: storage.namespace, // Set a default namespace value or set it as a parameter of the adapter function + async get(): Promise { + const storedKeys = await storage.getStoredKeys() + const defaults = storage.defaults + const storedValues = await Promise.all( + storedKeys.map(async (key) => { + const value = await storage.get(key) + return { [key]: value } + }), + ) + return Object.assign({}, defaults, ...storedValues) + }, + async set(value: T): Promise { + const old = await this.get() + + const changes = Object.keys(value).reduce((acc, key: keyof T) => { + if (value[key] !== old[key]) { + acc[key] = value[key] + } + return acc + }, {} as Partial) + + await Promise.all( + Object.keys(changes).map(async (key: keyof T) => { + await storage.set(key, changes[key] as T[keyof T]) + }), + ) + + // because subscribe is debounce for one tick, we should wait 1 tick before resolving the set + await new Promise((resolve) => setTimeout(resolve, 0)) + }, + subscribe( + callback: (value: StorageChange>) => void, + ): () => void { + const debounceTickCallback = debounce( + () => this.get().then(callback), + 0, + { leading: false }, + ) + let unsub: () => void = noop + void storage.getStoredKeys().then((keys) => { + const defaultsKeys = Object.keys(storage.defaults) + const allKeys = union(keys, defaultsKeys) + const unsubs = allKeys.map((key) => + storage.subscribe(key, debounceTickCallback), + ) + unsub = () => { + unsubs.forEach((unsub) => unsub()) + } + }) + + return unsub + }, + } +} diff --git a/packages/extension/src/shared/storage/__new/object.ts b/packages/extension/src/shared/storage/__new/object.ts new file mode 100644 index 000000000..9288d30c2 --- /dev/null +++ b/packages/extension/src/shared/storage/__new/object.ts @@ -0,0 +1,23 @@ +import { IObjectStore } from "./interface" +import { IObjectStorage } from ".." + +export function adaptObjectStorage( + storage: IObjectStorage, +): IObjectStore { + const { namespace } = storage + + return { + namespace, + async get() { + return storage.get() + }, + async set(value) { + await storage.set(value) + }, + subscribe(callback) { + return storage.subscribe((_value, changeSet) => { + callback(changeSet) + }) + }, + } +} diff --git a/packages/extension/src/shared/storage/__new/repositories/network.ts b/packages/extension/src/shared/storage/__new/repositories/network.ts new file mode 100644 index 000000000..0d0f1fb43 --- /dev/null +++ b/packages/extension/src/shared/storage/__new/repositories/network.ts @@ -0,0 +1,4 @@ +import { allNetworksStore } from "../../../network/storage" +import { adaptArrayStorage } from "../repository" + +export const networksRepository = adaptArrayStorage(allNetworksStore) diff --git a/packages/extension/src/shared/storage/__new/repository.ts b/packages/extension/src/shared/storage/__new/repository.ts new file mode 100644 index 000000000..e04d28acd --- /dev/null +++ b/packages/extension/src/shared/storage/__new/repository.ts @@ -0,0 +1,37 @@ +import { + AllowArray, + AllowPromise, + IRepository, + SelectorFn, + SetterFn, + StorageChange, + UpsertResult, +} from "./interface" +import { ArrayStorage } from ".." + +export function adaptArrayStorage(storage: ArrayStorage): IRepository { + return { + namespace: storage.namespace, + + async get(selector?: SelectorFn): Promise { + return storage.get(selector) + }, + + async upsert(value: AllowArray | SetterFn): Promise { + await storage.push(value) + return { created: Date.now(), updated: Date.now() } + }, + + async remove(value: AllowArray | SelectorFn): Promise { + return storage.remove(value) + }, + + subscribe( + callback: (changeSet: StorageChange) => AllowPromise, + ): () => void { + return storage.subscribe((_value: T[], changeSet: StorageChange) => + callback(changeSet), + ) + }, + } +} diff --git a/packages/extension/src/shared/storage/__test__/keyvalue.test.ts b/packages/extension/src/shared/storage/__test__/keyvalue.test.ts index 438af0b4e..fa2025a61 100644 --- a/packages/extension/src/shared/storage/__test__/keyvalue.test.ts +++ b/packages/extension/src/shared/storage/__test__/keyvalue.test.ts @@ -12,7 +12,7 @@ describe("full storage flow", () => { }) test("throw when storage area is invalid", () => { expect(() => { - new KeyValueStorage<{ foo: string }>( + store = new KeyValueStorage<{ foo: string }>( { foo: "bar" }, { namespace: "test", areaName: "invalid" as any }, ) diff --git a/packages/extension/src/shared/storage/hooks.ts b/packages/extension/src/shared/storage/hooks.ts index 50c602440..5216ca217 100644 --- a/packages/extension/src/shared/storage/hooks.ts +++ b/packages/extension/src/shared/storage/hooks.ts @@ -1,5 +1,5 @@ import { memoize } from "lodash-es" -import { useCallback, useEffect, useMemo, useState } from "react" +import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { swrCacheProvider } from "../../ui/services/swr" import { IArrayStorage } from "./array" @@ -76,13 +76,22 @@ export function useArrayStorage( [storage.namespace], ) + const selectorRef = useRef(selector) + + useEffect(() => { + selectorRef.current = selector + }, [selector]) + useEffect(() => { storage.get().then(set) const sub = storage.subscribe(set) return () => sub() - }, [selector, storage, set]) + }, [storage, set]) - const filteredValue = useMemo(() => value.filter(selector), [value, selector]) + const filteredValue = useMemo( + () => value.filter((item) => selectorRef.current(item)), + [value], + ) return filteredValue } diff --git a/packages/extension/src/shared/storage/keyvalue.ts b/packages/extension/src/shared/storage/keyvalue.ts index 11bd67316..a850a0289 100644 --- a/packages/extension/src/shared/storage/keyvalue.ts +++ b/packages/extension/src/shared/storage/keyvalue.ts @@ -103,4 +103,14 @@ export class KeyValueStorage< return () => browser.storage.onChanged.removeListener(handler) } + + /** + * @internal for migration purposes only + */ + public async getStoredKeys(): Promise { + const items = await this.storageImplementation.get(null) + return Object.keys(items) + .filter((key) => key.startsWith(this.namespace)) + .map((key) => key.replace(this.namespace + ":", "")) + } } diff --git a/packages/extension/src/shared/transactionReview.service.ts b/packages/extension/src/shared/transactionReview.service.ts index c2708462a..443d1a681 100644 --- a/packages/extension/src/shared/transactionReview.service.ts +++ b/packages/extension/src/shared/transactionReview.service.ts @@ -29,7 +29,7 @@ export type ApiTransactionReviewAssessmentReason = export type ApiTransactionReviewTargettedDapp = { name: string description: string - iconUrl: string + logoUrl: string links: { name: string url: string diff --git a/packages/extension/src/shared/transactions.ts b/packages/extension/src/shared/transactions.ts index 2b71f4d08..510f8224e 100644 --- a/packages/extension/src/shared/transactions.ts +++ b/packages/extension/src/shared/transactions.ts @@ -3,17 +3,31 @@ import { Call, Status, TransactionType, number } from "starknet" import { WalletAccount } from "./wallet.model" +export type ExtendedTransactionStatus = Status | "CANCELLED" + // Global Constants for Transactions -export const SUCCESS_STATUSES: Status[] = [ +export const SUCCESS_STATUSES: ExtendedTransactionStatus[] = [ "ACCEPTED_ON_L1", "ACCEPTED_ON_L2", "PENDING", ] -export const TRANSACTION_STATUSES_TO_TRACK: Status[] = [ +export const FAILED_STATUS: ExtendedTransactionStatus[] = [ + "REJECTED", + "CANCELLED", +] + +export const TRANSACTION_STATUSES_TO_TRACK: ExtendedTransactionStatus[] = [ "RECEIVED", "NOT_RECEIVED", ] +export type ExtendedTransactionType = + | TransactionType + | "MULTISIG_ADD_SIGNERS" + | "MULTISIG_UPDATE_THRESHOLD" + | "MULTISIG_REMOVE_SIGNER" + | "ADD_ARGENT_SHIELD" + | "REMOVE_ARGENT_SHIELD" export interface TransactionMeta { title?: string @@ -23,7 +37,7 @@ export interface TransactionMeta { isDeployAccount?: boolean isCancelEscape?: boolean transactions?: Call | Call[] - type?: TransactionType + type?: ExtendedTransactionType } export interface TransactionBase { @@ -39,7 +53,7 @@ export interface TransactionRequest extends TransactionBase { } export interface Transaction extends TransactionRequest { - status: Status + status: ExtendedTransactionStatus failureReason?: { code: string; error_message: string } timestamp: number } @@ -47,7 +61,7 @@ export interface Transaction extends TransactionRequest { export const compareTransactions = ( a: TransactionBase, b: TransactionBase, -): boolean => a.hash === b.hash && a.account.networkId === a.account.networkId +): boolean => a.hash === b.hash && a.account.networkId === b.account.networkId export function entryPointToHumanReadable(entryPoint: string): string { try { @@ -87,3 +101,19 @@ export function transactionNamesToTitle( : lastName return upperFirst(title) } + +export function transformEntrypointName(entryPoint: string) { + if (entryPoint === "changeThreshold") { + return "setConfirmations" + } else if (entryPoint === "addSigners") { + return "addOwner" + } else { + return entryPoint + } +} + +export const MULTISG_TXN_TYPES: ExtendedTransactionType[] = [ + "MULTISIG_ADD_SIGNERS", + "MULTISIG_UPDATE_THRESHOLD", + "MULTISIG_REMOVE_SIGNER", +] diff --git a/packages/extension/src/shared/types/deepPick.ts b/packages/extension/src/shared/types/deepPick.ts new file mode 100644 index 000000000..47a6f1bb9 --- /dev/null +++ b/packages/extension/src/shared/types/deepPick.ts @@ -0,0 +1,43 @@ +/** + * This file should probably be contributed to type-fest + */ + +import type { Simplify, UnionToIntersection } from "type-fest" + +type DeepPath< + T, + Depth extends number = 5, + CurrentDepth extends number[] = [], +> = 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 + +export type DeepPick< + T, + K extends DeepPath, + Depth extends number = 5, +> = Simplify< + UnionToIntersection< + { + [P in K]: P extends `${infer A}.${infer R}` + ? A extends keyof T + ? { [K in A]: DeepPick>> } + : never + : P extends keyof T + ? { [K in P]: T[P] } + : never + }[K] + > +> diff --git a/packages/extension/src/shared/utils/accountsMultisigSort.ts b/packages/extension/src/shared/utils/accountsMultisigSort.ts new file mode 100644 index 000000000..695e3943b --- /dev/null +++ b/packages/extension/src/shared/utils/accountsMultisigSort.ts @@ -0,0 +1,25 @@ +import { getIndexForPath } from "../../background/keys/keyDerivation" +import { Account } from "../../ui/features/accounts/Account" +import { PendingMultisig } from "../multisig/types" +import { WalletAccount } from "../wallet.model" +import { baseDerivationPath } from "../wallet.service" + +export const sortByDerivationPath = ( + a: PendingMultisig | WalletAccount | Account, + b: PendingMultisig | WalletAccount | Account, +) => { + const aIndex = getIndexForPath(a.signer.derivationPath, baseDerivationPath) + const bIndex = getIndexForPath(b.signer.derivationPath, baseDerivationPath) + return aIndex - bIndex +} + +type AccountType = Account | WalletAccount + +export const multisigAndAccountSort = ( + pending: PendingMultisig[], + full: T[], +): (PendingMultisig | T)[] => { + const sorted = [...pending, ...full] + sorted.sort(sortByDerivationPath) + return sorted +} diff --git a/packages/extension/src/shared/utils/encode.ts b/packages/extension/src/shared/utils/encode.ts new file mode 100644 index 000000000..8acac79b9 --- /dev/null +++ b/packages/extension/src/shared/utils/encode.ts @@ -0,0 +1,3 @@ +export function bytesToUft8(array: ArrayBuffer): string { + return new TextDecoder("utf-8").decode(array) +} diff --git a/packages/extension/src/shared/utils/starknetNetwork.ts b/packages/extension/src/shared/utils/starknetNetwork.ts new file mode 100644 index 000000000..3fe81fb7d --- /dev/null +++ b/packages/extension/src/shared/utils/starknetNetwork.ts @@ -0,0 +1,70 @@ +import { constants } from "starknet" + +import { Network } from "../network" + +export const networkToStarknetNetwork = (network: Network) => { + switch (network.chainId) { + case "SN_MAIN": + return "mainnet" + case "SN_GOERLI": + return "testnet" + case "SN_GOERLI2": + return "testnet2" + default: + return "testnet" + } +} + +export const networkIdToStarknetNetwork = (networkId: string) => { + switch (networkId) { + case "mainnet-alpha": + return "mainnet" + case "goerli-alpha": + return "testnet" + case "goerli-alpha-2": + return "testnet2" + default: + return "testnet" + } +} + +export const chainIdToStarknetNetwork = ( + chainId: constants.StarknetChainId, +) => { + switch (chainId) { + case constants.StarknetChainId.MAINNET: + return "mainnet" + case constants.StarknetChainId.TESTNET: + return "testnet" + case constants.StarknetChainId.TESTNET2: + return "testnet2" + default: + return "testnet" + } +} + +export const starknetNetworkToNetworkId = (network: string) => { + switch (network) { + case "mainnet": + return "mainnet-alpha" + case "testnet": + return "goerli-alpha" + case "testnet2": + return "goerli-alpha-2" + default: + return "goerli-alpha" + } +} + +export const networkIdToChainId = (networkId: string) => { + switch (networkId) { + case "mainnet-alpha": + return constants.StarknetChainId.MAINNET + case "goerli-alpha": + return constants.StarknetChainId.TESTNET + case "goerli-alpha-2": + return constants.StarknetChainId.TESTNET2 + default: + return constants.StarknetChainId.TESTNET + } +} diff --git a/packages/extension/src/shared/wallet.model.ts b/packages/extension/src/shared/wallet.model.ts index 6645c821d..5fed611b3 100644 --- a/packages/extension/src/shared/wallet.model.ts +++ b/packages/extension/src/shared/wallet.model.ts @@ -1,31 +1,80 @@ -import { Escape } from "./account/details/getEscape" -import { Network } from "./network" +import { z } from "zod" -export type ArgentAccountType = - | "argent" - | "argent-plugin" - | "argent-better-multicall" -export interface WalletAccountSigner { - type: "local_secret" - derivationPath: string -} +import { escapeSchema } from "./account/details/getEscape" +import { networkSchema } from "./network" -export interface WithSigner { - signer: WalletAccountSigner -} +export const argentAccountTypeSchema = z.enum([ + "standard", + "plugin", + "multisig", + "betterMulticall", + "argent5MinuteEscapeTestingAccount", +]) +export const createAccountTypeSchema = argentAccountTypeSchema.exclude([ + "plugin", + "betterMulticall", + "argent5MinuteEscapeTestingAccount", +]) +export const baseWalletAccountSchema = z.object({ + address: z.string(), + networkId: z.string(), +}) +export const walletAccountSignerSchema = z.object({ + type: z.literal("local_secret"), + derivationPath: z.string(), +}) +export const withSignerSchema = z.object({ + signer: walletAccountSignerSchema, +}) -export interface BaseWalletAccount { - address: string - networkId: string -} +export const walletAccountSchema = z + .object({ + name: z.string(), + network: networkSchema, + type: argentAccountTypeSchema, + hidden: z.boolean().optional(), + needsDeploy: z.boolean().optional(), + guardian: z.string().optional(), + escape: escapeSchema.optional(), + }) + .merge(withSignerSchema) + .merge(baseWalletAccountSchema) -export interface WalletAccount extends BaseWalletAccount, WithSigner { - network: Network - type: ArgentAccountType - hidden?: boolean - needsDeploy?: boolean - guardian?: string - escape?: Escape -} +export const storedWalletAccountSchema = walletAccountSchema.omit({ + network: true, +}) -export type StoredWalletAccount = Omit +export const multisigDataSchema = z.object({ + publicKey: z.string(), + signers: z.array(z.string()), + threshold: z.number(), + creator: z.string().optional(), // Creator is the public key of the account that created the multisig account +}) +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, + }) + .merge(walletAccountSchema) + +export type StoredWalletAccount = z.infer +export type MultisigData = z.infer +export type MultisigWalletAccount = z.infer +export type BaseMultisigWalletAccount = z.infer< + typeof baseMultisigWalletAccountSchema +> +export type WithSigner = z.infer +export type BaseWalletAccount = z.infer +export type WalletAccountSigner = z.infer +export type ArgentAccountType = z.infer +export type CreateAccountType = z.infer +export type WalletAccount = z.infer +export type CreateWalletAccount = z.infer diff --git a/packages/extension/src/shared/wallet.service.ts b/packages/extension/src/shared/wallet.service.ts index f90d855f7..e791949bc 100644 --- a/packages/extension/src/shared/wallet.service.ts +++ b/packages/extension/src/shared/wallet.service.ts @@ -40,5 +40,13 @@ export const accountsEqual = (a: BaseWalletAccount, b: BaseWalletAccount) => { } } +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 +}) => `${pendingMultisig.networkId}::${pendingMultisig.publicKey}` diff --git a/packages/extension/src/shared/wallet/storeMigration.ts b/packages/extension/src/shared/wallet/storeMigration.ts index ccb660c8e..dd806e995 100644 --- a/packages/extension/src/shared/wallet/storeMigration.ts +++ b/packages/extension/src/shared/wallet/storeMigration.ts @@ -2,7 +2,7 @@ import { isBoolean, isPlainObject } from "lodash-es" import browser from "webextension-polyfill" import { Wallet } from "../../background/wallet" -import { walletStore } from "../../shared/wallet/walletStore" +import { old_walletStore } from "../../shared/wallet/walletStore" import { migrateWalletAccounts } from "../account/storeMigration" export async function migrateWallet() { @@ -28,7 +28,7 @@ export async function migrateBackup() { if (!Wallet.validateBackup(oldWallet)) { throw new Error("Invalid backup") } - await walletStore.set("backup", oldWallet) + await old_walletStore.set("backup", oldWallet) return browser.storage.local.remove("wallet:backup") } catch (e) { console.log(e) @@ -49,7 +49,7 @@ export async function migrateDiscoveredOnce() { if (!isBoolean(oldDiscoveredOnce)) { throw new Error("Invalid discoveredOnce") } - await walletStore.set("discoveredOnce", oldDiscoveredOnce) + await old_walletStore.set("discoveredOnce", oldDiscoveredOnce) return browser.storage.local.remove("wallet:discoveredOnce") } catch (e) { console.log(e) @@ -74,7 +74,7 @@ export async function migrateSelected() { ) { throw new Error("Invalid selected") } - await walletStore.set("selected", oldSelected) + await old_walletStore.set("selected", oldSelected) return browser.storage.local.remove("wallet:selected") } catch (e) { console.log(e) diff --git a/packages/extension/src/shared/wallet/walletStore.ts b/packages/extension/src/shared/wallet/walletStore.ts index 958eed797..087b7cf2f 100644 --- a/packages/extension/src/shared/wallet/walletStore.ts +++ b/packages/extension/src/shared/wallet/walletStore.ts @@ -1,4 +1,6 @@ import { KeyValueStorage } from "../storage" +import { IObjectStore } from "../storage/__new/interface" +import { adaptKeyValue } from "../storage/__new/keyvalue" import { BaseWalletAccount } from "../wallet.model" export interface WalletStorageProps { @@ -7,7 +9,14 @@ export interface WalletStorageProps { discoveredOnce?: boolean } -export const walletStore = new KeyValueStorage( +export type IWalletStore = IObjectStore + +/** + * @deprecated use `walletStore` instead + */ +export const old_walletStore = new KeyValueStorage( {}, "core:wallet", ) + +export const walletStore: IWalletStore = adaptKeyValue(old_walletStore) diff --git a/packages/extension/src/ui/App.tsx b/packages/extension/src/ui/App.tsx index e193e3be9..d286c6e4b 100644 --- a/packages/extension/src/ui/App.tsx +++ b/packages/extension/src/ui/App.tsx @@ -7,7 +7,7 @@ import AppErrorBoundaryFallback from "./AppErrorBoundaryFallback" import { AppRoutes } from "./AppRoutes" import { ErrorBoundary } from "./components/ErrorBoundary" import { AppDimensions } from "./components/Responsive" -import { LoadingScreen } from "./features/actions/LoadingScreen" +import { LoadingScreenContainer } from "./features/actions/LoadingScreenContainer" import DevUI from "./features/dev/DevUI" import { useAutoEnableArgentShield } from "./features/shield/useArgentShieldEnabled" import { useCaptureEntryRouteRestorationState } from "./features/stateRestoration/useRestorationState" @@ -38,7 +38,7 @@ export const App: FC = () => { {process.env.SHOW_DEV_UI && } }> - }> + }> diff --git a/packages/extension/src/ui/AppRoutes.tsx b/packages/extension/src/ui/AppRoutes.tsx index 059b555a6..e4e977f8f 100644 --- a/packages/extension/src/ui/AppRoutes.tsx +++ b/packages/extension/src/ui/AppRoutes.tsx @@ -13,39 +13,49 @@ import { CollectionNfts } from "./features/accountNfts/CollectionNfts" import { NftScreen } from "./features/accountNfts/NftScreen" import { SendNftScreen } from "./features/accountNfts/SendNftScreen" import { AddPluginScreen } from "./features/accountPlugins.tsx/AddPluginScreen" -import { AccountListHiddenScreen } from "./features/accounts/AccountListHiddenScreen" -import { AccountListScreen } from "./features/accounts/AccountListScreen" +import { AccountListHiddenScreenContainer } from "./features/accounts/AccountListHiddenScreenContainer" +import { AccountListScreenContainer } from "./features/accounts/AccountListScreenContainer" import { AccountScreen } from "./features/accounts/AccountScreen" -import { AddNewAccountScreen } from "./features/accounts/AddNewAccountScreen" -import { HideOrDeleteAccountConfirmScreen } from "./features/accounts/HideOrDeleteAccountConfirmScreen" -import { UpgradeScreen } from "./features/accounts/UpgradeScreen" -import { UpgradeScreenV4 } from "./features/accounts/UpgradeScreenV4" +import { AddNewAccountScreenContainer } from "./features/accounts/AddNewAccountScreenContainer" +import { HideOrDeleteAccountConfirmScreenContainer } from "./features/accounts/HideOrDeleteAccountConfirmScreenContainer" +import { MigrationDisclaimerScreenContainer } from "./features/accounts/MigrationDisclaimerScreenContainer" +import { UpgradeScreenContainer } from "./features/accounts/UpgradeScreenContainer" +import { UpgradeScreenV4Container } from "./features/accounts/UpgradeScreenV4Container" import { ExportPrivateKeyScreen } from "./features/accountTokens/ExportPrivateKeyScreen" import { HideTokenScreen } from "./features/accountTokens/HideTokenScreen" import { SendTokenScreen } from "./features/accountTokens/SendTokenScreen" import { TokenScreen } from "./features/accountTokens/TokenScreen" import { useActions } from "./features/actions/actions.state" -import { ActionScreen } from "./features/actions/ActionScreen" -import { AddTokenScreen } from "./features/actions/AddTokenScreen" -import { ErrorScreen } from "./features/actions/ErrorScreen" -import { LoadingScreen } from "./features/actions/LoadingScreen" +import { ActionScreenContainer } from "./features/actions/ActionScreen" +import { AddTokenScreenContainer } from "./features/actions/AddTokenScreenContainer" +import { ErrorScreenContainer } from "./features/actions/ErrorScreenContainer" +import { LoadingScreenContainer } from "./features/actions/LoadingScreenContainer" import { FundingBridgeScreen } from "./features/funding/FundingBridgeScreen" import { FundingProviderScreen } from "./features/funding/FundingProviderScreen" import { FundingQrCodeScreen } from "./features/funding/FundingQrCodeScreen" import { FundingScreen } from "./features/funding/FundingScreen" import { LockScreen } from "./features/lock/LockScreen" import { ResetScreen } from "./features/lock/ResetScreen" -import { NetworkWarningScreen } from "./features/networks/NetworkWarningScreen" -import { MigrationDisclaimerScreen } from "./features/onboarding/MigrationDisclaimerScreen" -import { OnboardingDisclaimerScreen } from "./features/onboarding/OnboardingDisclaimerScreen" -import { OnboardingFinishScreen } from "./features/onboarding/OnboardingFinishScreen" -import { OnboardingPasswordScreen } from "./features/onboarding/OnboardingPasswordScreen" -import { OnboardingPrivacyStatementScreen } from "./features/onboarding/OnboardingPrivacyStatementScreen" -import { OnboardingRestoreBackup } from "./features/onboarding/OnboardingRestoreBackup" -import { OnboardingRestorePassword } from "./features/onboarding/OnboardingRestorePassword" -import { OnboardingRestoreSeed } from "./features/onboarding/OnboardingRestoreSeed" -import { OnboardingStartScreen } from "./features/onboarding/OnboardingStartScreen" -import { BackupDownloadScreen } from "./features/recovery/BackupDownloadScreen" +import { CreateMultisigStartScreen } from "./features/multisig/CreateMultisigScreen/CreateMultisigStartScreen" +import { JoinMultisigScreen } from "./features/multisig/JoinMultisigScreen" +import { JoinMultisigSettingsScreen } from "./features/multisig/JoinMultisigSettingsScreen" +import { MultisigAddOwnersScreen } from "./features/multisig/MultisigAddOwnersScreen" +import { MultisigConfirmationsScreen } from "./features/multisig/MultisigConfirmationsScreen" +import { MultisigOwnersScreen } from "./features/multisig/MultisigOwnersScreen" +import { MultisigPendingTransactionDetailsScreen } from "./features/multisig/MultisigPendingTransactionDetailsScreen" +import { MultisigRemoveOwnersScreen } from "./features/multisig/MultisigRemoveOwnerScreen" +import { MultisigTransactionConfirmationsScreen } from "./features/multisig/MultisigTransactionConfirmationsScreen" +import { NewMultisigScreen } from "./features/multisig/NewMultisigScreen" +import { RemovedMultisigSettingsScreenContainer } from "./features/multisig/RemovedMultisigSettingsScreenContainer" +import { NetworkWarningScreenContainer } from "./features/networks/NetworkWarningScreen/NetworkWarningScreenContainer" +import { OnboardingDisclaimerScreenContainer } from "./features/onboarding/OnboardingDisclaimerScreenContainer" +import { OnboardingFinishScreenContainer } from "./features/onboarding/OnboardingFinishScreenContainer" +import { OnboardingPasswordScreenContainer } from "./features/onboarding/OnboardingPasswordScreenContainer" +import { OnboardingPrivacyStatementScreenContainer } from "./features/onboarding/OnboardingPrivacyStatementScreenContainer" +import { OnboardingRestoreBackupScreenContainer } from "./features/onboarding/OnboardingRestoreBackupScreenContainer" +import { OnboardingRestorePasswordScreenContainer } from "./features/onboarding/OnboardingRestorePasswordScreenContainer" +import { OnboardingRestoreSeedScreenContainer } from "./features/onboarding/OnboardingRestoreSeedScreenContainer" +import { OnboardingStartScreenContainer } from "./features/onboarding/OnboardingStartScreenContainer" import { RecoverySetupScreen } from "./features/recovery/RecoverySetupScreen" import { SeedRecoveryConfirmScreen } from "./features/recovery/SeedRecoveryConfirmScreen" import { SeedRecoverySetupScreen } from "./features/recovery/SeedRecoverySetupScreen" @@ -106,12 +116,12 @@ const ResponsiveRoutes: FC = () => ( const nonWalletRoutes = ( <> - } /> + } /> } /> } /> } + element={} /> ) @@ -133,7 +143,7 @@ const walletRoutes = ( } + element={} /> } + element={} /> } + element={} /> } /> + } + /> } + element={} /> } + element={} /> - } /> + } /> } /> } + element={} /> } + element={} /> } + element={} /> } /> } /> - } /> + } /> } /> } /> - } - /> } /> + + {/* Multisig */} + } /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> ) @@ -389,41 +448,45 @@ const fullscreenRoutes = ( <> } + element={} /> } + element={} /> } + element={} /> } + element={} /> } + element={} /> } + element={} /> } + element={} /> } + element={} /> } /> } /> + } + /> ) @@ -449,13 +512,13 @@ export const AppRoutes: FC = () => { }, [actions, location.pathname]) if (isLoading) { - return + return } if (showActions) { return ( - + ) } diff --git a/packages/extension/src/ui/app.state.ts b/packages/extension/src/ui/app.state.ts index 0ef3a99fa..848c8b21c 100644 --- a/packages/extension/src/ui/app.state.ts +++ b/packages/extension/src/ui/app.state.ts @@ -1,5 +1,5 @@ import { useEffect, useState } from "react" -import create from "zustand" +import { create } from "zustand" import { messageStream } from "../shared/messages" import { defaultNetwork } from "../shared/network" @@ -13,7 +13,7 @@ interface State { isFirstRender: boolean } -export const useAppState = create(() => ({ +export const useAppState = create()(() => ({ switcherNetworkId: defaultNetwork.id, isLoading: true, isFirstRender: true, diff --git a/packages/extension/src/ui/components/Column.tsx b/packages/extension/src/ui/components/Column.tsx index 3fb1d0c24..d80ebe44b 100644 --- a/packages/extension/src/ui/components/Column.tsx +++ b/packages/extension/src/ui/components/Column.tsx @@ -5,7 +5,7 @@ const Column = styled.div<{ gap?: string }>` flex-direction: column; justify-content: flex-start; - gap: ${({ gap }) => gap && gap}; + gap: ${({ gap }) => gap}; ` export const ColumnCenter = styled(Column)` width: 100%; @@ -30,7 +30,7 @@ export const AutoColumn = styled.div<{ (gap === "md" && "12px") || (gap === "lg" && "24px") || gap}; - justify-items: ${({ justify }) => justify && justify}; + 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 new file mode 100644 index 000000000..b1a905132 --- /dev/null +++ b/packages/extension/src/ui/components/ControlledInput.tsx @@ -0,0 +1,38 @@ +import { FieldError } from "@argent/ui" +import { Input, InputProps } from "@chakra-ui/react" +import { FC } from "react" +import { Control, Controller, FieldPath, FieldValues } from "react-hook-form" + +interface ControlledInputProps + extends InputProps { + control: Control + name: FieldPath +} + +export const ControlledInput: FC = ({ + control, + name, + children, + ...props +}) => { + return ( + ( + <> + + {children} + + {error && {error.message}} + + )} + /> + ) +} diff --git a/packages/extension/src/ui/components/CustomButtonCell.tsx b/packages/extension/src/ui/components/CustomButtonCell.tsx index 864fe09cc..e754b90e2 100644 --- a/packages/extension/src/ui/components/CustomButtonCell.tsx +++ b/packages/extension/src/ui/components/CustomButtonCell.tsx @@ -1,7 +1,8 @@ import { Button } from "@argent/ui" -import { ComponentProps, FC } from "react" +import { ButtonProps } from "@chakra-ui/react" +import { FC } from "react" -export interface CustomButtonCellProps extends ComponentProps { +export interface CustomButtonCellProps extends ButtonProps { highlighted?: boolean transparent?: boolean } diff --git a/packages/extension/src/ui/components/Fields.tsx b/packages/extension/src/ui/components/Fields.tsx index 758b5a1a8..1f046478d 100644 --- a/packages/extension/src/ui/components/Fields.tsx +++ b/packages/extension/src/ui/components/Fields.tsx @@ -20,22 +20,6 @@ export const Field = styled.div<{ clickable?: boolean }>` `} ` -export const FieldAlt = styled(Field)` - padding: 16px 10px; -` - -export const FieldError = styled.div<{ justify?: string }>` - color: ${({ theme }) => theme.text1}; - background-color: ${({ theme }) => theme.red1}; - display: flex; - justify-content: ${({ justify }) => (justify ? justify : "center")}; - align-items: center; - - padding: 8px 10px; - font-size: 12px; - line-height: 15px; -` - export const FieldGroup = styled.section<{ error?: boolean }>` background: ${({ theme }) => theme.bg2}; border-radius: 8px; @@ -71,10 +55,6 @@ export const FieldKeyGroup = styled.div` justify-content: center; ` -export const FieldValueGroup = styled(FieldKeyGroup)` - align-items: flex-end; -` - export const FieldKey = styled(FieldValue)<{ withoutColor?: boolean }>` ${({ withoutColor = false }) => !withoutColor && @@ -83,24 +63,6 @@ export const FieldKey = styled(FieldValue)<{ withoutColor?: boolean }>` `} ` -export const FieldKeySub = styled(FieldKey)` - font-size: 13px; - line-height: 18px; -` - -export const FieldValueSub = styled(FieldValue)` - font-size: 13px; - line-height: 18px; -` - -export const FieldKeyMeta = styled(FieldKey)` - font-size: 12px; - line-height: 14px; - margin-top: 2px; -` - -export const FieldValueMeta = styled(FieldKeyMeta)`` - export const LeftPaddedField = styled.div` margin-left: 8px; text-align: right; diff --git a/packages/extension/src/ui/components/FullScreenPage.tsx b/packages/extension/src/ui/components/FullScreenPage.tsx index 3839e6f17..6e91008de 100644 --- a/packages/extension/src/ui/components/FullScreenPage.tsx +++ b/packages/extension/src/ui/components/FullScreenPage.tsx @@ -1,42 +1,49 @@ -import styled from "styled-components" +import { Center, Flex, chakra } from "@chakra-ui/react" -export const Panel = styled.div` - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - width: 100%; - padding: 0 56px; -` +export const Panel = chakra(Center, { + baseStyle: { + flexDirection: "column", + width: "full", + px: 0, + py: 14, + _last: { + width: [null, "40%"], + height: [null, "100%"], + }, + }, +}) -export const PageWrapper = styled.div` - display: flex; - flex-direction: column-reverse; - align-items: center; - justify-content: flex-end; - width: 100%; - margin-top: max(120px, 15vh); +export const DecoratedPanel = chakra(Panel, { + baseStyle: { + _last: { + background: [ + null, + `url('./assets/onboarding-background.svg') no-repeat center`, + ], + backgroundSize: [null, "cover"], + }, + }, +}) - ${({ theme }) => theme.mediaMinWidth.md` - flex-direction: row; - margin-top: 0; - height: 100vh; +export const PageWrapper = chakra(Flex, { + baseStyle: { + flexDirection: ["column-reverse", "row"], + alignItems: "center", + justifyContent: "flex-end", + width: "full", + mt: ["max(120px, 15vh)", 0], + height: [null, "100vh"], + }, +}) - > ${Panel}:last-child { - width: 40%; - display: flex; - background-color: black; - height: 100%; - } - `} -` - -export const ContentWrapper = styled.div` - margin: 32px auto; - display: flex; - flex-direction: column; - align-items: flex-start; - justify-content: center; - width: 100%; - max-width: 600px; -` +export const ContentWrapper = chakra(Flex, { + baseStyle: { + mx: "auto", + my: 4, + flexDirection: "column", + alignItems: "flex-start", + justifyContent: "center", + width: "full", + maxWidth: "600px", + }, +}) diff --git a/packages/extension/src/ui/components/IOSSwitch.tsx b/packages/extension/src/ui/components/IOSSwitch.tsx index 49263ffef..43376ad4c 100644 --- a/packages/extension/src/ui/components/IOSSwitch.tsx +++ b/packages/extension/src/ui/components/IOSSwitch.tsx @@ -18,7 +18,7 @@ const IOSSwitch = styled((props: SwitchProps) => ( transform: "translateX(16px)", color: "#fff", "& + .MuiSwitch-track": { - backgroundColor: theme.palette.mode === "dark" ? "#02BBA8" : "#02BBA8", + backgroundColor: "#02BBA8", opacity: 1, border: 0, }, diff --git a/packages/extension/src/ui/components/PrivacyStatementLink.tsx b/packages/extension/src/ui/components/PrivacyStatementLink.tsx deleted file mode 100644 index 5aa705123..000000000 --- a/packages/extension/src/ui/components/PrivacyStatementLink.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { ComponentProps, FC } from "react" -import { Link } from "react-router-dom" -import styled from "styled-components" - -const StyledLink = styled(Link)` - color: ${({ theme }) => theme.text3}; - font-weight: 400; - font-size: 12px; - line-height: 14px; - text-align: center; - text-decoration-line: underline; -` - -export const PrivacyStatementLink: FC> = ({ - ...props -}) => { - return Privacy Statement -} diff --git a/packages/extension/src/ui/components/Row.tsx b/packages/extension/src/ui/components/Row.tsx index 5c017da3e..2e2ba0a93 100644 --- a/packages/extension/src/ui/components/Row.tsx +++ b/packages/extension/src/ui/components/Row.tsx @@ -14,7 +14,7 @@ const Row = styled.div<{ padding: ${({ padding }) => padding}; border: ${({ border }) => border}; border-radius: ${({ borderRadius }) => borderRadius}; - gap: ${({ gap }) => gap && gap}; + gap: ${({ gap }) => gap}; ` export const RowBetween = styled(Row)` @@ -37,7 +37,7 @@ export const RowFlat = styled.div` export const AutoRow = styled(Row)<{ gap?: string; justify?: string }>` flex-wrap: wrap; margin: ${({ gap }) => gap && `-${gap}`}; - justify-content: ${({ justify }) => justify && justify}; + justify-content: ${({ justify }) => justify}; & > * { margin: ${({ gap }) => gap} !important; diff --git a/packages/extension/src/ui/components/StatusIndicator.tsx b/packages/extension/src/ui/components/StatusIndicator.tsx index a4107b648..647492fe4 100644 --- a/packages/extension/src/ui/components/StatusIndicator.tsx +++ b/packages/extension/src/ui/components/StatusIndicator.tsx @@ -1,3 +1,4 @@ +import { Box } from "@chakra-ui/react" import { FC } from "react" import styled, { css, keyframes } from "styled-components" @@ -29,20 +30,27 @@ export function mapNetworkStatusToColor( } } -export const StatusIndicator = styled.span` - height: 8px; - width: 8px; - border-radius: 8px; - - background-color: ${({ color = "transparent" }) => - color === "green" - ? "#02BBA8" - : color === "orange" - ? "#ffa85c" - : color === "red" - ? "#C12026" - : "transparent"}; -` +export const StatusIndicator = ({ + color = "transparent", +}: { + color: StatusIndicatorColor +}) => ( + +) export const NetworkStatusIndicator: FC = ({ color = "transparent", diff --git a/packages/extension/src/ui/components/utils/isAllowedAddressHexInputValue.tsx b/packages/extension/src/ui/components/utils/isAllowedAddressHexInputValue.tsx index ff13302df..91fc215f2 100644 --- a/packages/extension/src/ui/components/utils/isAllowedAddressHexInputValue.tsx +++ b/packages/extension/src/ui/components/utils/isAllowedAddressHexInputValue.tsx @@ -1,5 +1,5 @@ export const isAllowedAddressHexInputValue = (value: string) => { - const hexRegex = /^(|0|0x([a-f0-9A-F]+)?)$/ + const hexRegex = /^(0|0x([a-f0-9A-F]+)?)$/ if (value === "") { return true } diff --git a/packages/extension/src/ui/features/accountActivity/AccountActivity.tsx b/packages/extension/src/ui/features/accountActivity/AccountActivity.tsx index 4838ff94b..8115b7670 100644 --- a/packages/extension/src/ui/features/accountActivity/AccountActivity.tsx +++ b/packages/extension/src/ui/features/accountActivity/AccountActivity.tsx @@ -66,7 +66,7 @@ const AccountActivityItem: FC = ({ }) => { const navigate = useNavigate() if (isActivityTransaction(transaction)) { - const { hash, isRejected } = transaction + const { hash, isRejected, isCancelled } = transaction const transactionTransformed = transformTransaction({ transaction, accountAddress: account.address, @@ -80,6 +80,7 @@ const AccountActivityItem: FC = ({ transactionTransformed={transactionTransformed} network={account.network} onClick={() => navigate(routes.transactionDetail(hash))} + isCancelled={isCancelled} > {isRejected ? (
diff --git a/packages/extension/src/ui/features/accountActivity/AccountActivityContainer.tsx b/packages/extension/src/ui/features/accountActivity/AccountActivityContainer.tsx index c68f559d3..5e1f36f4a 100644 --- a/packages/extension/src/ui/features/accountActivity/AccountActivityContainer.tsx +++ b/packages/extension/src/ui/features/accountActivity/AccountActivityContainer.tsx @@ -13,8 +13,10 @@ import { useAspectContractAddresses } from "../accountNfts/aspect.service" import { Account } from "../accounts/Account" import { useAccountTransactions } from "../accounts/accountTransactions.state" import { useTokensInNetwork } from "../accountTokens/tokens.state" -import { useCurrentNetwork } from "../networks/useNetworks" +import { useMultisigPendingTransactionsByAccount } from "../multisig/multisigTransactions.state" +import { useCurrentNetwork } from "../networks/hooks/useCurrentNetwork" import { AccountActivity } from "./AccountActivity" +import { PendingMultisigTransactions } from "./PendingMultisigTransactions" import { PendingTransactions } from "./PendingTransactions" import { isVoyagerTransaction } from "./transform/is" import { ActivityTransaction } from "./useActivity" @@ -58,6 +60,8 @@ export const AccountActivityLoader: FC = ({ const { switcherNetworkId } = useAppState() const tokensByNetwork = useTokensInNetwork(switcherNetworkId) const { data: nftContractAddresses } = useAspectContractAddresses() + const pendingMultisigTransactions = + useMultisigPendingTransactionsByAccount(account) const { data, setSize, error, isValidating } = useArgentExplorerAccountTransactionsInfinite({ @@ -160,11 +164,13 @@ export const AccountActivityLoader: FC = ({ if (isVoyagerTransaction(transaction)) { const { hash, meta, status } = transaction const isRejected = status === "REJECTED" + const isCancelled = status === "CANCELLED" const activityTransaction: ActivityTransaction = { hash, date, meta, isRejected, + isCancelled, } mergedActivity[dateLabel].push(activityTransaction) } else { @@ -209,7 +215,11 @@ export const AccountActivityLoader: FC = ({ ) } - if (!pendingTransactions.length && !Object.keys(mergedActivity).length) { + if ( + !pendingTransactions.length && + !Object.keys(mergedActivity).length && + !pendingMultisigTransactions?.length + ) { return ( } title={"No activity for this network"} /> ) @@ -217,6 +227,14 @@ export const AccountActivityLoader: FC = ({ return ( <> + {pendingMultisigTransactions && + pendingMultisigTransactions.length > 0 && ( + + )} = ({ + pendingTransactions, + account, + network, +}) => { + const multisig = useMultisig(account) + if (!multisig) { + return null + } + if (!pendingTransactions.length) { + return null + } + + const [selfPendingTxns, othersPendingTxns] = partition( + pendingTransactions, + (pendingTransaction) => + pendingTransaction.nonApprovedSigners.includes(multisig.publicKey), + ) + + return ( + <> + {othersPendingTxns.length > 0 && ( + <> + + + Waiting for others to confirm + + + + + )} + {selfPendingTxns.length > 0 && ( + <> + + + Awaiting review +
+ {selfPendingTxns.length} +
+
+
+ + + )} + + ) +} + +interface PendingMultisigTransactionContainerProps + extends PendingTransactionsProps { + multisig: Multisig +} + +export const PendingMultisigTransactionContainer: FC< + PendingMultisigTransactionContainerProps +> = ({ pendingTransactions, account, multisig, network }) => { + const navigate = useNavigate() + + const getConfirmationSubtext = memoize((approvedSigners: string[]) => { + return `${approvedSigners.length} confirmation${ + approvedSigners.length === 1 ? "" : "s" + } • ${multisig.threshold - approvedSigners.length} remaining ` + }) + return ( + <> + {pendingTransactions.map((pendingTransaction) => { + const transaction = getTransactionFromPendingMultisigTransaction( + pendingTransaction, + account, + ) + const transactionTransformed = transformTransaction({ + transaction, + accountAddress: account.address, + }) + if (transactionTransformed) { + const { hash } = transaction + const onClick = () => { + navigate( + routes.multisigPendingTransactionDetails( + account.address, + pendingTransaction.requestId, + ), + ) + } + return ( + + + + + + {getConfirmationSubtext(pendingTransaction.approvedSigners)} + + + Click to review + + + + ) + } + return null + })} + + ) +} diff --git a/packages/extension/src/ui/features/accountActivity/TransactionDetail.tsx b/packages/extension/src/ui/features/accountActivity/TransactionDetail.tsx index f5bc5d333..ac9f59bb2 100644 --- a/packages/extension/src/ui/features/accountActivity/TransactionDetail.tsx +++ b/packages/extension/src/ui/features/accountActivity/TransactionDetail.tsx @@ -76,7 +76,7 @@ const MainTransactionIconContainer = styled.div` margin-bottom: 8px; ` -const Date = styled.div` +const DateContainer = styled.div` font-weight: 400; font-size: 13px; color: ${({ theme }) => theme.text2}; @@ -310,7 +310,9 @@ export const TransactionDetail: FC = ({ )} - {date ? formatDateTime(date) : "Unknown date"} + + {date ? formatDateTime(date) : "Unknown date"} + } > diff --git a/packages/extension/src/ui/features/accountActivity/TransactionDetailScreen.tsx b/packages/extension/src/ui/features/accountActivity/TransactionDetailScreen.tsx index 3601aa53a..aa3b6de97 100644 --- a/packages/extension/src/ui/features/accountActivity/TransactionDetailScreen.tsx +++ b/packages/extension/src/ui/features/accountActivity/TransactionDetailScreen.tsx @@ -5,12 +5,13 @@ import { Navigate, useParams } from "react-router-dom" import { compareTransactions } from "../../../shared/transactions" import { useAppState } from "../../app.state" import { routes } from "../../routes" +import { selectedAccountView } from "../../views/account" +import { useView } from "../../views/implementation/react" import { useAspectContractAddresses } from "../accountNfts/aspect.service" -import { useSelectedAccount } from "../accounts/accounts.state" import { useAccountTransactions } from "../accounts/accountTransactions.state" import { useTokensInNetwork } from "../accountTokens/tokens.state" -import { LoadingScreen } from "../actions/LoadingScreen" -import { useCurrentNetwork } from "../networks/useNetworks" +import { LoadingScreenContainer } from "../actions/LoadingScreenContainer" +import { useCurrentNetwork } from "../networks/hooks/useCurrentNetwork" import { TransactionDetail } from "./TransactionDetail" import { transformExplorerTransaction, transformTransaction } from "./transform" import { useArgentExplorerTransaction } from "./useArgentExplorer" @@ -28,7 +29,7 @@ export const TransactionDetailScreen: FC = () => { }) const isInitialLoad = !explorerTransaction && !error && isValidating - const account = useSelectedAccount() + const account = useView(selectedAccountView) const { switcherNetworkId } = useAppState() const tokensByNetwork = useTokensInNetwork(switcherNetworkId) const { data: nftContractAddresses } = useAspectContractAddresses() @@ -104,7 +105,7 @@ export const TransactionDetailScreen: FC = () => { } if (isInitialLoad) { - return + return } if ( diff --git a/packages/extension/src/ui/features/accountActivity/TransactionListItem.tsx b/packages/extension/src/ui/features/accountActivity/TransactionListItem.tsx index 1e964153f..6b56fe9ca 100644 --- a/packages/extension/src/ui/features/accountActivity/TransactionListItem.tsx +++ b/packages/extension/src/ui/features/accountActivity/TransactionListItem.tsx @@ -3,7 +3,10 @@ import { Flex } from "@chakra-ui/react" import { FC, ReactNode, useMemo } from "react" import { Network } from "../../../shared/network" -import { CustomButtonCell } from "../../components/CustomButtonCell" +import { + CustomButtonCell, + CustomButtonCellProps, +} from "../../components/CustomButtonCell" import { PrettyAccountAddress } from "../accounts/PrettyAccountAddress" import { isDeclareContractTransaction, @@ -22,21 +25,24 @@ import { SwapTransactionIcon } from "./ui/SwapTransactionIcon" import { TransactionIcon } from "./ui/TransactionIcon" import { TransferAccessory } from "./ui/TransferAccessory" -export interface TransactionListItemProps { +export interface TransactionListItemProps extends CustomButtonCellProps { transactionTransformed: TransformedTransaction network: Network highlighted?: boolean - onClick?: () => void children?: ReactNode | ReactNode[] txHash: string + isCancelled?: boolean } export const TransactionListItem: FC = ({ transactionTransformed, network, highlighted, - children, txHash, + isCancelled, + onClick, + _active, + children, ...props }) => { const { action, displayName, dapp } = transactionTransformed @@ -56,14 +62,33 @@ export const TransactionListItem: FC = ({ (action === "SEND" || action === "TRANSFER") const { toAddress, fromAddress } = transactionTransformed return ( - <> - {titleShowsTo ? "To: " : "From: "} - - + + + {titleShowsTo ? "To: " : "From: "} + + {isCancelled && <> ∙ } + + + {isCancelled && ( + + Cancelled + + )} + ) } if (dapp) { @@ -78,13 +103,14 @@ export const TransactionListItem: FC = ({ return null }, [ isTransfer, - dapp, isNFTTransfer, + dapp, isDeclareContract, isDeployContract, action, transactionTransformed, network.id, + isCancelled, ]) const icon = useMemo(() => { @@ -108,8 +134,14 @@ export const TransactionListItem: FC = ({ ) } - return - }, [isNFT, isSwap, transactionTransformed, network.id]) + return ( + + ) + }, [isNFT, isSwap, transactionTransformed, isCancelled, network.id]) const accessory = useMemo(() => { if (isTransfer || isTokenMint || isTokenApprove) { @@ -122,7 +154,13 @@ export const TransactionListItem: FC = ({ }, [isTransfer, isTokenMint, isTokenApprove, isSwap, transactionTransformed]) return ( - + !isCancelled && onClick?.(e)} + _hover={{ cursor: isCancelled ? "default" : "pointer" }} + _active={!isCancelled ? _active : {}} + {...props} + > {icon} = ({
{displayName}
- - {subtitle} - + {subtitle}
{accessory} diff --git a/packages/extension/src/ui/features/accountActivity/transform/transaction/__test__/transformTransaction.test.ts b/packages/extension/src/ui/features/accountActivity/transform/transaction/__test__/transformTransaction.test.ts index e5ee73f2e..a437441fd 100644 --- a/packages/extension/src/ui/features/accountActivity/transform/transaction/__test__/transformTransaction.test.ts +++ b/packages/extension/src/ui/features/accountActivity/transform/transaction/__test__/transformTransaction.test.ts @@ -23,11 +23,12 @@ const accountAddress = const makeTransaction = (transactions: Call | Call[]): Transaction => { return { account: { + name: "Account 1", address: accountAddress, - type: "argent", + type: "standard", network: { accountClassHash: { - argentAccount: + standard: "0x389a968f62e344b2e08a50e091987797a74b34840840022fd797769230a9d3f", }, baseUrl: "https://alpha4.starknet.io", @@ -38,6 +39,7 @@ const makeTransaction = (transactions: Call | Call[]): Transaction => { "0x042a12c5a641619a6c58e623d5735273cdfb0e13df72c4bacb4e188892034bd6", name: "Goerli Testnet", readonly: true, + status: "unknown", }, networkId: "goerli-alpha", signer: { @@ -231,7 +233,7 @@ describe("transformTransaction", () => { { "action": "ADD", "date": "2022-09-01T15:47:40.000Z", - "displayName": "Add Argent Shield", + "displayName": "Activate Argent Shield", "entity": "GUARDIAN", } `) @@ -243,7 +245,7 @@ describe("transformTransaction", () => { { "action": "REMOVE", "date": "2022-09-01T15:47:40.000Z", - "displayName": "Remove Argent Shield", + "displayName": "Deactivate Argent Shield", "entity": "GUARDIAN", } `) diff --git a/packages/extension/src/ui/features/accountActivity/transform/transaction/transformTransaction.ts b/packages/extension/src/ui/features/accountActivity/transform/transaction/transformTransaction.ts index 6a5ca1dd4..999967f4c 100644 --- a/packages/extension/src/ui/features/accountActivity/transform/transaction/transformTransaction.ts +++ b/packages/extension/src/ui/features/accountActivity/transform/transaction/transformTransaction.ts @@ -2,6 +2,8 @@ import { Token } from "../../../../../shared/token/type" import { Transaction } from "../../../../../shared/transactions" import { ActivityTransaction } from "../../useActivity" import { TransformedTransaction } from "../type" +import changeMultisigThresholdTransformer from "./transformers/changeMultisigThresholdTransformer" +import addMultisigTransformer from "./transformers/changeMultisigTransformer" import dateTransformer from "./transformers/dateTransformer" import declareContractTransformer from "./transformers/declareContractTransformer" import defaultDisplayNameTransformer from "./transformers/defaultDisplayNameTransformer" @@ -28,6 +30,8 @@ const mainTransformers = [ tokenMintTransformer, tokenTransferTransformer, guardianTransformer, + addMultisigTransformer, + changeMultisigThresholdTransformer, ] /** all are executed */ diff --git a/packages/extension/src/ui/features/accountActivity/transform/transaction/transformers/changeMultisigThresholdTransformer.ts b/packages/extension/src/ui/features/accountActivity/transform/transaction/transformers/changeMultisigThresholdTransformer.ts new file mode 100644 index 000000000..bcf282337 --- /dev/null +++ b/packages/extension/src/ui/features/accountActivity/transform/transaction/transformers/changeMultisigThresholdTransformer.ts @@ -0,0 +1,22 @@ +import { isChangeTresholdMultisigCall } from "../../../../../../shared/call/setMultisigThresholdCalls" +import { ChangeMultisigThresholdTransaction } from "../../type" +import { getCallsFromTransaction } from "../getCallsFromTransaction" +import { ITransactionTransformer } from "./type" + +export default function ({ transaction, result }: ITransactionTransformer) { + const calls = getCallsFromTransaction(transaction) + for (const call of calls) { + if (isChangeTresholdMultisigCall(call)) { + const action = "CHANGE" + const entity = "THRESHOLD" + const displayName = "Set confirmations" + result = { + ...result, + action, + entity, + displayName, + } as ChangeMultisigThresholdTransaction + return result + } + } +} diff --git a/packages/extension/src/ui/features/accountActivity/transform/transaction/transformers/changeMultisigTransformer.ts b/packages/extension/src/ui/features/accountActivity/transform/transaction/transformers/changeMultisigTransformer.ts new file mode 100644 index 000000000..7d5c1ffbe --- /dev/null +++ b/packages/extension/src/ui/features/accountActivity/transform/transaction/transformers/changeMultisigTransformer.ts @@ -0,0 +1,27 @@ +import { + isAddMultisigSignersCall, + isRemoveMultisigSignersCall, +} from "../../../../../../shared/call/changeMultisigSignersCall" +import { ChangeMultisigSignerTransaction } from "../../type" +import { getCallsFromTransaction } from "../getCallsFromTransaction" +import { ITransactionTransformer } from "./type" + +export default function ({ transaction, result }: ITransactionTransformer) { + const calls = getCallsFromTransaction(transaction) + for (const call of calls) { + if (isAddMultisigSignersCall(call) || isRemoveMultisigSignersCall(call)) { + const action = isAddMultisigSignersCall(call) ? "ADD" : "REMOVE" + const entity = "SIGNER" + const displayName = isAddMultisigSignersCall(call) + ? "Add multisig owner" + : "Remove multisig owner" + result = { + ...result, + action, + entity, + displayName, + } as ChangeMultisigSignerTransaction + return result + } + } +} diff --git a/packages/extension/src/ui/features/accountActivity/transform/transaction/transformers/guardianTransformer.ts b/packages/extension/src/ui/features/accountActivity/transform/transaction/transformers/guardianTransformer.ts index a2e1b7a97..ccded7523 100644 --- a/packages/extension/src/ui/features/accountActivity/transform/transaction/transformers/guardianTransformer.ts +++ b/packages/extension/src/ui/features/accountActivity/transform/transaction/transformers/guardianTransformer.ts @@ -16,8 +16,8 @@ export default function ({ transaction, result }: ITransactionTransformer) { const action = isRemove ? "REMOVE" : "ADD" const entity = "GUARDIAN" const displayName = isRemove - ? "Remove Argent Shield" - : "Add Argent Shield" + ? "Deactivate Argent Shield" + : "Activate Argent Shield" result = { ...result, action, diff --git a/packages/extension/src/ui/features/accountActivity/transform/transaction/transformers/pendingMultisigTransactionAdapter.ts b/packages/extension/src/ui/features/accountActivity/transform/transaction/transformers/pendingMultisigTransactionAdapter.ts new file mode 100644 index 000000000..f43d59647 --- /dev/null +++ b/packages/extension/src/ui/features/accountActivity/transform/transaction/transformers/pendingMultisigTransactionAdapter.ts @@ -0,0 +1,19 @@ +import { MultisigPendingTransaction } from "../../../../../../shared/multisig/pendingTransactionsStore" +import { Transaction } from "../../../../../../shared/transactions" +import { WalletAccount } from "../../../../../../shared/wallet.model" + +export const getTransactionFromPendingMultisigTransaction = ( + pendingMultisigTransaction: MultisigPendingTransaction, + account: WalletAccount, +): Transaction => { + const { timestamp, transaction } = pendingMultisigTransaction + return { + account, + meta: { + transactions: transaction.calls, + }, + timestamp, + status: "NOT_RECEIVED", + hash: pendingMultisigTransaction.transactionHash, + } +} diff --git a/packages/extension/src/ui/features/accountActivity/transform/type.ts b/packages/extension/src/ui/features/accountActivity/transform/type.ts index 666c96bc4..461b26ee9 100644 --- a/packages/extension/src/ui/features/accountActivity/transform/type.ts +++ b/packages/extension/src/ui/features/accountActivity/transform/type.ts @@ -16,6 +16,7 @@ export type TransformedTransactionAction = | "DEPLOY" | "ADD" | "REMOVE" + | "CHANGE" export type TransformedTransactionEntity = | "UNKNOWN" @@ -25,6 +26,8 @@ export type TransformedTransactionEntity = | "NFT" | "CONTRACT" | "GUARDIAN" + | "SIGNER" + | "THRESHOLD" export interface BaseTransformedTransaction { action: TransformedTransactionAction @@ -107,6 +110,18 @@ export interface ChangeGuardianTransaction extends BaseTransformedTransaction { entity: "GUARDIAN" } +export interface ChangeMultisigSignerTransaction + extends BaseTransformedTransaction { + action: "ADD" | "REMOVE" + entity: "SIGNER" +} + +export interface ChangeMultisigThresholdTransaction + extends BaseTransformedTransaction { + action: "CHANGE" + entity: "THRESHOLD" +} + export type TransformedTransaction = | BaseTransformedTransaction | TokenTransferTransaction @@ -118,3 +133,5 @@ export type TransformedTransaction = | DeclareContractTransaction | DeployContractTransaction | ChangeGuardianTransaction + | ChangeMultisigSignerTransaction + | ChangeMultisigThresholdTransaction diff --git a/packages/extension/src/ui/features/accountActivity/ui/LoadMoreTrigger.tsx b/packages/extension/src/ui/features/accountActivity/ui/LoadMoreTrigger.tsx index 453a0b1ff..08330eff5 100644 --- a/packages/extension/src/ui/features/accountActivity/ui/LoadMoreTrigger.tsx +++ b/packages/extension/src/ui/features/accountActivity/ui/LoadMoreTrigger.tsx @@ -1,7 +1,7 @@ -import { Box } from "@chakra-ui/react" -import { ComponentProps, FC, useEffect, useRef } from "react" +import { Box, BoxProps } from "@chakra-ui/react" +import { FC, useEffect, useRef } from "react" -interface LoadMoreProps extends ComponentProps { +interface LoadMoreProps extends BoxProps { onLoadMore: () => void oneShot?: boolean } diff --git a/packages/extension/src/ui/features/accountActivity/ui/NFTImage.tsx b/packages/extension/src/ui/features/accountActivity/ui/NFTImage.tsx index 5df48b830..de3324d94 100644 --- a/packages/extension/src/ui/features/accountActivity/ui/NFTImage.tsx +++ b/packages/extension/src/ui/features/accountActivity/ui/NFTImage.tsx @@ -1,9 +1,9 @@ -import { Image } from "@chakra-ui/react" -import { ComponentProps, FC } from "react" +import { Image, ImageProps } from "@chakra-ui/react" +import { FC } from "react" import { useAspectNft } from "../../accountNfts/aspect.service" -export interface NFTImageProps extends ComponentProps { +export interface NFTImageProps extends ImageProps { contractAddress?: string tokenId?: string networkId: string diff --git a/packages/extension/src/ui/features/accountActivity/ui/SwapTransactionIcon.tsx b/packages/extension/src/ui/features/accountActivity/ui/SwapTransactionIcon.tsx index 02a35bc6f..70a17492e 100644 --- a/packages/extension/src/ui/features/accountActivity/ui/SwapTransactionIcon.tsx +++ b/packages/extension/src/ui/features/accountActivity/ui/SwapTransactionIcon.tsx @@ -1,12 +1,11 @@ -import { Square } from "@chakra-ui/react" -import { ComponentProps, FC } from "react" +import { Square, SquareProps } from "@chakra-ui/react" +import { FC } from "react" import { TokenIcon } from "../../accountTokens/TokenIcon" import { isSwapTransaction } from "../transform/is" import { TransformedTransaction } from "../transform/type" -export interface SwapTransactionIconProps - extends Omit, "size"> { +export interface SwapTransactionIconProps extends Omit { transaction: TransformedTransaction size: string | number } diff --git a/packages/extension/src/ui/features/accountActivity/ui/TransactionIcon.tsx b/packages/extension/src/ui/features/accountActivity/ui/TransactionIcon.tsx index 86f37a27d..333ef011b 100644 --- a/packages/extension/src/ui/features/accountActivity/ui/TransactionIcon.tsx +++ b/packages/extension/src/ui/features/accountActivity/ui/TransactionIcon.tsx @@ -1,5 +1,5 @@ import { icons } from "@argent/ui" -import { Circle, Image } from "@chakra-ui/react" +import { Circle, Image, SquareProps } from "@chakra-ui/react" import { ComponentProps, FC } from "react" import { getTokenIconUrl } from "../../accountTokens/TokenIcon" @@ -23,18 +23,23 @@ const { ActivityIcon, ArgentShieldIcon, ArgentShieldDeactivateIcon, + MultisigJoinIcon, + MultisigRemoveIcon, + FailIcon, } = icons -export interface TransactionIconProps - extends Omit, "outline"> { +export interface TransactionIconProps extends Omit { transaction: TransformedTransaction outline?: boolean + isCancelled?: boolean + size?: number } export const TransactionIcon: FC = ({ transaction, size = 18, outline = false, + isCancelled = false, ...rest }) => { const badgeSize = Math.min(32, Math.round((size * 16) / 36)) @@ -50,6 +55,13 @@ export const TransactionIcon: FC = ({ iconComponent = action === "ADD" ? : break + case "SIGNER": + iconComponent = + action === "ADD" ? : + break + case "THRESHOLD": + iconComponent = + break } switch (action) { case "SEND": @@ -112,7 +124,7 @@ export const TransactionIcon: FC = ({ fontSize={iconSize} {...rest} > - {iconComponent} + {isCancelled ? : iconComponent} {badgeComponent && ( { + const currentNetwork = useCurrentNetwork() + const { accountAddress = "" } = useParams<{ accountAddress: string }>() + const navigate = useNavigate() + const account = useAccount({ + address: accountAddress, + networkId: currentNetwork.id, + }) + const blockExplorerTitle = useBlockExplorerTitle() + const liveAccountGuardianState = useLiveAccountGuardianState(account) + + const { feeTokenBalance } = useFeeTokenBalance(account) + + const canDeployAccount = useMemo( + () => account?.needsDeploy && feeTokenBalance?.gt(0), + [account?.needsDeploy, feeTokenBalance], + ) + + const argentShieldEnabled = useArgentShieldEnabled() + + const experimentalAllowChooseAccount = useKeyValueStorage( + settingsStore, + "experimentalAllowChooseAccount", + ) + + const showDelete = + account && (isDeprecated(account) || account.networkId === "localhost") + + const handleHideOrDeleteAccount = async (account: Account) => { + if (showDelete) { + navigate(routes.accountDeleteConfirm(account.address)) + } else { + navigate(routes.accountHideConfirm(account.address)) + } + } + + const { status, type, hasGuardian } = liveAccountGuardianState + const isAdding = type === ChangeGuardian.ADDING + + const accountSubtitle = useMemo(() => { + if (status === "ERROR") { + return isAdding + ? "Adding Argent Shield Failed" + : "Removing Argent Shield Failed" + } + if (status === "PENDING") { + return isAdding ? "Adding Argent Shield…" : "Removing Argent Shield…" + } + return "Two-factor account protection" + }, [isAdding, status]) + + const shieldIsLoading = liveAccountGuardianState.status === "PENDING" + + const handleDeploy = async () => { + if (account) { + await accountService.deploy(account) + } + } + return ( + <> + {argentShieldEnabled && ( + <> + + } + rightIcon={ + shieldIsLoading ? ( + + ) : ( + + navigate(routes.shieldAccountStart(accountAddress)) + } + /> + ) + } + > + <>Argent Shield + + {accountSubtitle} + + + + + )} + {account && !account.needsDeploy && ( + + account && openBlockExplorerAddress(currentNetwork, account.address) + } + rightIcon={} + > + View on {blockExplorerTitle} + + )} + account && handleHideOrDeleteAccount(account)}> + {showDelete ? "Delete account" : "Hide account"} + + {experimentalAllowChooseAccount && account && ( + { + navigate(routes.accountImplementations(account.address)) + }} + > + Change account implementation + + )} + {canDeployAccount && ( + Deploy account + )} + navigate(routes.exportPrivateKey(accountAddress))} + > + Export private key + + + ) +} diff --git a/packages/extension/src/ui/features/accountEdit/AccountEditButtonsMultisig.tsx b/packages/extension/src/ui/features/accountEdit/AccountEditButtonsMultisig.tsx new file mode 100644 index 000000000..43381bded --- /dev/null +++ b/packages/extension/src/ui/features/accountEdit/AccountEditButtonsMultisig.tsx @@ -0,0 +1,74 @@ +import { ButtonCell, H6, icons } from "@argent/ui" +import { Button, Flex } from "@chakra-ui/react" +import { useNavigate } from "react-router-dom" + +import { routes } from "../../routes" +import { + openBlockExplorerAddress, + useBlockExplorerTitle, +} from "../../services/blockExplorer.service" +import { Account } from "../accounts/Account" +import { useMultisig } from "../multisig/multisig.state" +import { useCurrentNetwork } from "../networks/hooks/useCurrentNetwork" + +const { ExpandIcon, HideIcon, ChevronRightIcon } = icons + +export const AccountEditButtonsMultisig = ({ + account, +}: { + account: Account +}) => { + const currentNetwork = useCurrentNetwork() + const navigate = useNavigate() + const blockExplorerTitle = useBlockExplorerTitle() + const multisig = useMultisig(account) + const handleHideAccount = async (account: Account) => { + navigate(routes.accountHideConfirm(account.address)) + } + + return ( + <> + navigate(routes.multisigOwners(account.address))} + > + {account.needsDeploy ? <>View owners : <>Manage owners} + + + {account && !account.needsDeploy && ( + + account && openBlockExplorerAddress(currentNetwork, account.address) + } + rightIcon={} + > + View on {blockExplorerTitle} + + )} + account && handleHideAccount(account)} + rightIcon={} + > + Hide account + + + ) +} diff --git a/packages/extension/src/ui/features/accountEdit/AccountEditName.tsx b/packages/extension/src/ui/features/accountEdit/AccountEditName.tsx index 5ffd6cc6f..cf13a4b10 100644 --- a/packages/extension/src/ui/features/accountEdit/AccountEditName.tsx +++ b/packages/extension/src/ui/features/accountEdit/AccountEditName.tsx @@ -3,22 +3,16 @@ import { Input, InputGroup, InputLeftElement, + InputProps, InputRightElement, } from "@chakra-ui/react" -import { - ComponentProps, - FC, - FormEvent, - useCallback, - useRef, - useState, -} from "react" +import { FC, FormEvent, useCallback, useRef, useState } from "react" import { useOnClickOutside } from "../../services/useOnClickOutside" const { EditIcon, TickIcon } = icons -interface AccountNameProps extends ComponentProps { +interface AccountNameProps extends InputProps { onSubmit: () => void onCancel: () => void } diff --git a/packages/extension/src/ui/features/accountEdit/AccountEditScreen.tsx b/packages/extension/src/ui/features/accountEdit/AccountEditScreen.tsx index fca50711d..614cdb4e1 100644 --- a/packages/extension/src/ui/features/accountEdit/AccountEditScreen.tsx +++ b/packages/extension/src/ui/features/accountEdit/AccountEditScreen.tsx @@ -1,64 +1,33 @@ import { BarBackButton, - ButtonCell, CellStack, NavigationContainer, - P4, SpacerCell, - Switch, - icons, } from "@argent/ui" -import { Center, Flex, Image, Spinner } from "@chakra-ui/react" -import { FC, useCallback, useMemo, useState } from "react" -import { Link, useNavigate, useParams } from "react-router-dom" +import { Center, Flex, Image } from "@chakra-ui/react" +import React, { FC, useCallback, useState } from "react" +import { useNavigate, useParams } from "react-router-dom" -import { settingsStore } from "../../../shared/settings" -import { useKeyValueStorage } from "../../../shared/storage/hooks" -import { parseAmount } from "../../../shared/token/amount" -import { getFeeToken } from "../../../shared/token/utils" -import { isDeprecated } from "../../../shared/wallet.service" +import { accountService } from "../../../shared/account/service" import { AddressCopyButton } from "../../components/AddressCopyButton" -import { routes, useReturnTo } from "../../routes" -import { - openBlockExplorerAddress, - useBlockExplorerTitle, -} from "../../services/blockExplorer.service" -import { - getUint256CalldataFromBN, - sendTransaction, -} from "../../services/transactions" -import { Account } from "../accounts/Account" -import { - getAccountName, - useAccountMetadata, -} from "../accounts/accountMetadata.state" +import { useReturnTo } from "../../routes" import { getNetworkAccountImageUrl } from "../accounts/accounts.service" import { useAccount } from "../accounts/accounts.state" -import { useCurrentNetwork } from "../networks/useNetworks" -import { useArgentShieldEnabled } from "../shield/useArgentShieldEnabled" -import { - ChangeGuardian, - useLiveAccountGuardianState, -} from "../shield/usePendingChangingGuardian" +import { useCurrentNetwork } from "../networks/hooks/useCurrentNetwork" +import { AccountEditButtons } from "./AccountEditButtons" +import { AccountEditButtonsMultisig } from "./AccountEditButtonsMultisig" import { AccountEditName } from "./AccountEditName" -const { ExpandIcon, ArgentShieldIcon } = icons - export const AccountEditScreen: FC = () => { const currentNetwork = useCurrentNetwork() const { accountAddress = "" } = useParams<{ accountAddress: string }>() const navigate = useNavigate() const returnTo = useReturnTo() - const { accountNames, setAccountName } = useAccountMetadata() const account = useAccount({ address: accountAddress, networkId: currentNetwork.id, }) - const accountName = account - ? getAccountName(account, accountNames) - : "Not found" - const blockExplorerTitle = useBlockExplorerTitle() - const liveAccountGuardianState = useLiveAccountGuardianState(account) + const accountName = account ? account.name : "Not found" const [liveEditingAccountName, setLiveEditingAccountName] = useState(accountName) @@ -71,69 +40,22 @@ export const AccountEditScreen: FC = () => { } }, [navigate, returnTo]) - const argentShieldEnabled = useArgentShieldEnabled() - - const experimentalAllowChooseAccount = useKeyValueStorage( - settingsStore, - "experimentalAllowChooseAccount", - ) - - const showDelete = - account && (isDeprecated(account) || account.networkId === "localhost") - - const handleHideOrDeleteAccount = async (account: Account) => { - if (showDelete) { - navigate(routes.accountDeleteConfirm(account.address)) - } else { - navigate(routes.accountHideConfirm(account.address)) - } - } - const onChangeName = useCallback((name: string) => { setLiveEditingAccountName(name) }, []) const onSubmitChangeName = useCallback(() => { - account && - setAccountName(account.networkId, account.address, liveEditingAccountName) - }, [account, liveEditingAccountName, setAccountName]) + if (!account) { + return + } + + void accountService.setName(liveEditingAccountName, account) + }, [account, liveEditingAccountName]) const onCancelChangeName = useCallback(() => { setLiveEditingAccountName(accountName) }, [accountName]) - const { status, type, hasGuardian } = liveAccountGuardianState - const isAdding = type === ChangeGuardian.ADDING - - const accountSubtitle = useMemo(() => { - if (status === "ERROR") { - return isAdding - ? "Adding Argent Shield Failed" - : "Removing Argent Shield Failed" - } - if (status === "PENDING") { - return isAdding ? "Adding Argent Shield…" : "Removing Argent Shield…" - } - return "Two-factor account protection" - }, [isAdding, status]) - - const shieldIsLoading = liveAccountGuardianState.status === "PENDING" - - const handleDeploy = () => { - const feeToken = getFeeToken(currentNetwork.id)?.address - if (account && feeToken) { - const ONE_GWEI = getUint256CalldataFromBN(parseAmount("1", 0)) - const self = account.address - sendTransaction({ - to: feeToken, - method: "transfer", - calldata: { - recipient: self, - amount: ONE_GWEI, - }, - }) - } - } return ( <> { - {argentShieldEnabled && ( - <> - - } - rightIcon={ - shieldIsLoading ? ( - - ) : ( - - navigate(routes.shieldAccountStart(accountAddress)) - } - /> - ) - } - > - <>Argent Shield - - {accountSubtitle} - - - - - )} - - account && - openBlockExplorerAddress(currentNetwork, account.address) - } - rightIcon={} - > - View on {blockExplorerTitle} - - account && handleHideOrDeleteAccount(account)} - > - {showDelete ? "Delete account" : "Hide account"} - - {experimentalAllowChooseAccount && account && ( - { - navigate(routes.accountImplementations(account.address)) - }} - > - Change account implementation - - )} - {account?.needsDeploy && ( - Deploy account + {account?.type === "multisig" && account ? ( + + ) : ( + )} - navigate(routes.exportPrivateKey(accountAddress))} - > - Export private key - diff --git a/packages/extension/src/ui/features/accountEdit/AccountImplementationScreen.tsx b/packages/extension/src/ui/features/accountEdit/AccountImplementationScreen.tsx index a87935583..8cfddb940 100644 --- a/packages/extension/src/ui/features/accountEdit/AccountImplementationScreen.tsx +++ b/packages/extension/src/ui/features/accountEdit/AccountImplementationScreen.tsx @@ -11,16 +11,13 @@ import { filter, partition } from "lodash-es" import { FC, ReactNode } from "react" import { useNavigate } from "react-router-dom" -import { mapArgentAccountTypeToImplementationKey } from "../../../shared/network/utils" +import { accountService } from "../../../shared/account/service" import { ArgentAccountType } from "../../../shared/wallet.model" import { accountsEqual } from "../../../shared/wallet.service" import { AutoColumn } from "../../components/Column" import { routes } from "../../routes" -import { - selectAccount, - upgradeAccount, -} from "../../services/backgroundAccounts" -import { useSelectedAccount } from "../accounts/accounts.state" +import { selectedAccountView } from "../../views/account" +import { useView } from "../../views/implementation/react" import { useRouteAccount } from "../shield/useRouteAccount" const { WalletIcon, PluginIcon, MulticallIcon, TickIcon } = icons @@ -33,19 +30,19 @@ interface Implementation { } const implementations: Implementation[] = [ { - id: "argent", + id: "standard", title: "Default", description: "The default Argent account implementation", icon: , }, { - id: "argent-plugin", + id: "plugin", title: "Plugin", description: "The Argent account implementation with plugin support", icon: , }, { - id: "argent-better-multicall", + id: "betterMulticall", title: "Better multicall", description: "The Argent account implementation with better multicall support", @@ -84,7 +81,7 @@ const ImplementationItem: FC = ({ } export const AccountImplementationScreen: FC = () => { - const selectedAccount = useSelectedAccount() + const selectedAccount = useView(selectedAccountView) const account = useRouteAccount() const navigate = useNavigate() @@ -96,19 +93,15 @@ export const AccountImplementationScreen: FC = () => { const handleImplementationClick = (i: Implementation) => async () => { if (!isSelectedAccount) { - await selectAccount(account) + await accountService.select(account) } - await upgradeAccount(account, i.id) + await accountService.upgrade(account, i.id) navigate(routes.accountTokens(), { replace: true }) } const [[activeImplementation], otherImplementations] = partition( filter(implementations, (i) => - Boolean( - account.network.accountClassHash?.[ - mapArgentAccountTypeToImplementationKey(i.id) - ], - ), + Boolean(account.network.accountClassHash?.[i.id]), ), (i) => i.id === account.type, ) diff --git a/packages/extension/src/ui/features/accountNfts/AccountCollections.tsx b/packages/extension/src/ui/features/accountNfts/AccountCollections.tsx index f0f1f7502..0c75be279 100644 --- a/packages/extension/src/ui/features/accountNfts/AccountCollections.tsx +++ b/packages/extension/src/ui/features/accountNfts/AccountCollections.tsx @@ -9,6 +9,7 @@ import { Spinner } from "../../components/Spinner" import { routes } from "../../routes" import { Account } from "../accounts/Account" import { Collection } from "./aspect.model" +import { getNftPicture } from "./aspect.service" import { EmptyCollections } from "./EmptyCollections" import { NftFigure } from "./NftFigure" import { NftItem } from "./NftItem" @@ -54,7 +55,7 @@ const Collections: FC = ({ > diff --git a/packages/extension/src/ui/features/accountNfts/CollectionNfts.tsx b/packages/extension/src/ui/features/accountNfts/CollectionNfts.tsx index 80520b38c..c6d3791c5 100644 --- a/packages/extension/src/ui/features/accountNfts/CollectionNfts.tsx +++ b/packages/extension/src/ui/features/accountNfts/CollectionNfts.tsx @@ -1,17 +1,20 @@ -import { BarBackButton, H1, H4, H6, NavigationContainer, P4 } from "@argent/ui" -import { Flex, Image, SimpleGrid } from "@chakra-ui/react" +import { BarBackButton, H3, H4, H6, NavigationContainer, P4 } from "@argent/ui" +import { Box, Flex, Image, SimpleGrid } from "@chakra-ui/react" import { ethers } from "ethers" -import { FC } from "react" +import { get } from "lodash-es" +import React, { FC, useMemo } from "react" import { Location, useLocation, useNavigate, useParams } from "react-router-dom" import { Spinner } from "../../components/Spinner" import { routes } from "../../routes" -import { useSelectedAccount } from "../accounts/accounts.state" +import { selectedAccountView } from "../../views/account" +import { useView } from "../../views/implementation/react" import { UnknownDappIcon } from "../actions/transaction/ApproveTransactionScreen/DappHeader/TransactionIcon/UnknownDappIcon" import { getNftPicture } from "./aspect.service" +import { NftFallback } from "./NftFallback" import { NftFigure } from "./NftFigure" import { NftItem } from "./NftItem" -import { useCollection } from "./useCollections" +import { NftZodError, ParsedError, useCollection } from "./useCollections" interface LocationWithState extends Location { state: { @@ -21,19 +24,54 @@ interface LocationWithState extends Location { export const CollectionNfts: FC = () => { const { contractAddress } = useParams<{ contractAddress: string }>() - const account = useSelectedAccount() + const account = useView(selectedAccountView) const navigate = useNavigate() const { state } = useLocation() as LocationWithState const navigateToSend = state?.navigateToSend || false - const { collectible, error } = useCollection(contractAddress, account) - if (!contractAddress) { - return <> + const errorMap: ParsedError | null = useMemo(() => { + if (!error) { + return null + } + try { + const parsedError = (error && JSON.parse(error)) || [] + return parsedError.reduce((a: ParsedError[], e: NftZodError) => { + return { + ...a, + [e.path[0]]: e.code, + } + }, {}) + } catch { + // error is not a json + return { + message: error.message, + } + } + }, [error]) + + // if no collectibles or no contract address, display generic error + if ((error && !collectible) || !contractAddress) { + return ( + navigate(routes.accountCollections())} + /> + } + > +

+ Error loading nfts +

+ + + +
+ ) } - if (error) { + if (errorMap?.message) { return ( { /> } > -

- Error loading -

+

+ {errorMap.message} +

) } @@ -99,27 +137,57 @@ export const CollectionNfts: FC = () => { mx="4" py={6} > - {collectible.nfts.map((nft) => ( - - navigate( - navigateToSend - ? routes.sendNft(nft.contract_address, nft.token_id) - : routes.accountNft(nft.contract_address, nft.token_id), - ) - } - > - - + {collectible.nfts.map((nft, index) => ( + + {!get(errorMap, index) && ( + + navigate( + navigateToSend + ? routes.sendNft(nft.contract_address, nft.token_id) + : routes.accountNft( + nft.contract_address, + nft.token_id, + ), + ) + } + > + + + )} + {errorMap && errorMap[index] && ( + // eslint-disable-next-line @typescript-eslint/no-empty-function + + + + )} + ))} diff --git a/packages/extension/src/ui/features/accountNfts/NftModelViewer.tsx b/packages/extension/src/ui/features/accountNfts/NftModelViewer.tsx index 3b02f66d1..1bf27e3e3 100644 --- a/packages/extension/src/ui/features/accountNfts/NftModelViewer.tsx +++ b/packages/extension/src/ui/features/accountNfts/NftModelViewer.tsx @@ -1,4 +1,4 @@ -import "@google/model-viewer/lib/model-viewer" +import "@google/model-viewer/dist/model-viewer" import { FC } from "react" diff --git a/packages/extension/src/ui/features/accountNfts/NftScreen.tsx b/packages/extension/src/ui/features/accountNfts/NftScreen.tsx index d90184b7b..0b18b89a0 100644 --- a/packages/extension/src/ui/features/accountNfts/NftScreen.tsx +++ b/packages/extension/src/ui/features/accountNfts/NftScreen.tsx @@ -27,7 +27,8 @@ import { Schema, object } from "yup" import { routes } from "../../routes" import { addressSchema, isEqualAddress } from "../../services/addresses" -import { useSelectedAccount } from "../accounts/accounts.state" +import { selectedAccountView } from "../../views/account" +import { useView } from "../../views/implementation/react" import { TokenMenu } from "../accountTokens/TokenMenu" import { getNftPicture } from "./aspect.service" import { useNfts } from "./useNfts" @@ -47,7 +48,7 @@ export const SendNftSchema: Schema = object().required().shape({ export const NftScreen: FC = () => { const navigate = useNavigate() const { contractAddress, tokenId } = useParams() - const account = useSelectedAccount() + const account = useView(selectedAccountView) const { nfts = [] } = useNfts(account) const nft = nfts diff --git a/packages/extension/src/ui/features/accountNfts/SendNftScreen.tsx b/packages/extension/src/ui/features/accountNfts/SendNftScreen.tsx index 2e5f9277c..aa990783c 100644 --- a/packages/extension/src/ui/features/accountNfts/SendNftScreen.tsx +++ b/packages/extension/src/ui/features/accountNfts/SendNftScreen.tsx @@ -7,6 +7,7 @@ import styled from "styled-components" import { Schema, object } from "yup" import { AddressBookContact } from "../../../shared/addressBook" +import { WalletAccount } from "../../../shared/wallet.model" import { AddContactBottomSheet } from "../../components/AddContactBottomSheet" import { Button } from "../../components/Button" import Column, { ColumnCenter } from "../../components/Column" @@ -34,15 +35,11 @@ import { import { useOnClickOutside } from "../../services/useOnClickOutside" import { getAddressFromStarkName } from "../../services/useStarknetId" import { H3, H5 } from "../../theme/Typography" -import { Account } from "../accounts/Account" -import { - getAccountName, - useAccountMetadata, -} from "../accounts/accountMetadata.state" +import { selectedAccountView } from "../../views/account" +import { useView } from "../../views/implementation/react" +import { AccountAvatar } from "../accounts/AccountAvatar" import { getAccountImageUrl } from "../accounts/accounts.service" -import { useSelectedAccount } from "../accounts/accounts.state" import { AddressBookMenu } from "../accounts/AddressBookMenu" -import { ProfilePicture } from "../accounts/ProfilePicture" import { AddressBookRecipient, AtTheRateWrapper, @@ -52,7 +49,7 @@ import { StyledAccountAddress, } from "../accountTokens/SendTokenScreen" import { TokenMenuDeprecated } from "../accountTokens/TokenMenuDeprecated" -import { useCurrentNetwork } from "../networks/useNetworks" +import { useCurrentNetwork } from "../networks/hooks/useCurrentNetwork" import { useYupValidationResolver } from "../settings/useYupValidationResolver" import { useNfts } from "./useNfts" @@ -94,7 +91,7 @@ export const SendNftSchema: Schema = object().required().shape({ export const SendNftScreen: FC = () => { const navigate = useNavigate() const { contractAddress, tokenId } = useParams() - const account = useSelectedAccount() + const account = useView(selectedAccountView) const { nfts = [] } = useNfts(account) @@ -107,10 +104,8 @@ export const SendNftScreen: FC = () => { const { id: currentNetworkId } = useCurrentNetwork() const [addressBookRecipient, setAddressBookRecipient] = useState< - Account | AddressBookContact + WalletAccount | AddressBookContact >() - - const { accountNames } = useAccountMetadata() const [bottomSheetOpen, setBottomSheetOpen] = useState(false) const [starknetIdLoading, setStarknetIdLoading] = useState(false) @@ -119,9 +114,9 @@ export const SendNftScreen: FC = () => { addressBookRecipient ? "name" in addressBookRecipient ? addressBookRecipient.name - : getAccountName(addressBookRecipient, accountNames) + : account?.name : undefined, - [accountNames, addressBookRecipient], + [account?.name, addressBookRecipient], ) const { @@ -206,7 +201,9 @@ export const SendNftScreen: FC = () => { navigate(routes.accountActivity(), { replace: true }) } - const handleAddressSelect = (account?: Account | AddressBookContact) => { + const handleAddressSelect = ( + account?: WalletAccount | AddressBookContact, + ) => { if (!account) { return } @@ -281,36 +278,37 @@ export const SendNftScreen: FC = () => {
+ {/** TODO: refactor - same pattern used in SendTokenScreen */} {addressBookRecipient && accountName ? ( - setAddressBookRecipient(undefined)} - > - - - - - -
{accountName}
- - {formatTruncatedAddress( - addressBookRecipient.address, + <> + setAddressBookRecipient(undefined)} + > + + + -
-
- -
-
+ size={8} + /> + +
{accountName}
+ + {formatTruncatedAddress( + addressBookRecipient.address, + )} + +
+ + + + + ) : (
export type AspectNftContract = z.infer export type AspectContract = z.infer export type Collection = z.infer +export type AspectCollection = z.infer diff --git a/packages/extension/src/ui/features/accountNfts/aspect.service.ts b/packages/extension/src/ui/features/accountNfts/aspect.service.ts index 20a2f4a31..26bb7d3ac 100644 --- a/packages/extension/src/ui/features/accountNfts/aspect.service.ts +++ b/packages/extension/src/ui/features/accountNfts/aspect.service.ts @@ -7,6 +7,7 @@ import { fetcher } from "../../../shared/api/fetcher" import { BaseWalletAccount } from "../../../shared/wallet.model" import { withPolling } from "../../services/swr" import { + AspectCollection, AspectContract, AspectNft, AspectNftArraySchema, @@ -60,7 +61,7 @@ export const fetchNextAspectNftsByUrl = async ( ): Promise => { const response = await fetch(url) if (!response.ok) { - return [] + throw new Error("Failed to fetch collection") } const data = await response.json() @@ -114,7 +115,7 @@ export const fetchNextAspectCollection = async ( ): Promise => { const response = await fetch(url) if (!response.ok) { - return [] + throw new Error("Failed to fetch collection") } const data = await response.json() @@ -132,7 +133,7 @@ export const fetchNextAspectContractAddresses = async ( ): Promise => { const response = await fetch(url) if (!response.ok) { - return [] + throw new Error("Failed to fetch collection") } const data = await response.json() @@ -181,6 +182,26 @@ export const useAspectNft = ( ) } +/** + * Fetch a NFT Collection from Aspect + * This is different from useCollection hook which fetches Collection owned by a user + * @param contractAddress + * @param networkId + * @param swrConfig + */ + +export const useAspectCollection = ( + contractAddress: string | undefined, + networkId: string, + swrConfig?: SWRConfiguration, +) => { + const url = + networkId === "goerli-alpha" + ? `https://api-testnet.aspect.co/api/v0/contract/${contractAddress}` + : `https://api.aspect.co/api/v0/contract/${contractAddress}` + return useSWR(contractAddress && url, fetcher, swrConfig) +} + export const openAspectNft = ( contractAddress: string, tokenId: string, diff --git a/packages/extension/src/ui/features/accountNfts/useCollections.ts b/packages/extension/src/ui/features/accountNfts/useCollections.ts index e9f4e0fce..d232a55d9 100644 --- a/packages/extension/src/ui/features/accountNfts/useCollections.ts +++ b/packages/extension/src/ui/features/accountNfts/useCollections.ts @@ -10,33 +10,61 @@ import { useNfts } from "./useNfts" type SerialisedCollectibles = Record +export type ParsedError = { + [key: string]: string +} + +export type NftZodError = { + code: string + expected: string + received: string + path: [number, string, string] + message: string +} + export const useCollections = ( account?: BaseWalletAccount, config?: SWRConfigCommon, ): Collection[] => { - const { nfts = [] } = useNfts(account, config) - return useMemo( - () => - Object.values( - nfts.filter(Boolean).reduce((acc, nft) => { + const { nfts = [], error } = useNfts(account, config) + return useMemo(() => { + let parsedError = null + let errorMap: ParsedError | null = null + try { + parsedError = (error && JSON.parse(error)) || [] + errorMap = parsedError.reduce((a: ParsedError[], e: NftZodError) => { + return { + ...a, + [e.path[0]]: e.code, + } + }, {}) + } catch { + parsedError = null + } + + return Object.values( + nfts + .filter(Boolean) + .reduce((acc, nft, currentIndex) => { + const hasError = errorMap && errorMap[currentIndex] if (acc[nft.contract_address]) { - acc[nft.contract_address].nfts.push(nft) + acc[nft.contract_address].nfts.push( + hasError ? { ...nft, image_url_copy: "" } : nft, + ) return acc } - return { ...acc, [nft.contract_address]: { name: nft.contract.name_custom || nft.contract.name || "Untitled", contractAddress: nft.contract.contract_address, imageUri: nft.contract.image_url, - nfts: [nft], + nfts: [hasError ? { ...nft, image_url_copy: "" } : nft], }, } }, {}), - ), - [nfts], - ) + ) + }, [error, nfts]) } export const useCollection = ( diff --git a/packages/extension/src/ui/features/accountPlugins.tsx/Plugin.tsx b/packages/extension/src/ui/features/accountPlugins.tsx/Plugin.tsx index bc570e52a..97a0b3861 100644 --- a/packages/extension/src/ui/features/accountPlugins.tsx/Plugin.tsx +++ b/packages/extension/src/ui/features/accountPlugins.tsx/Plugin.tsx @@ -10,7 +10,7 @@ import { Spinner } from "../../components/Spinner" import { H5 } from "../../theme/Typography" import { useAccount } from "../accounts/accounts.state" import { useAccountTransactions } from "../accounts/accountTransactions.state" -import { useCurrentNetwork } from "../networks/useNetworks" +import { useCurrentNetwork } from "../networks/hooks/useCurrentNetwork" import { PluginAccount } from "./PluginAccount" import { IPlugin } from "./Plugins" import { useIsPlugin } from "./useIsPlugin" diff --git a/packages/extension/src/ui/features/accountPlugins.tsx/PluginAccount.ts b/packages/extension/src/ui/features/accountPlugins.tsx/PluginAccount.ts index 26717a5d0..c18ae6f47 100644 --- a/packages/extension/src/ui/features/accountPlugins.tsx/PluginAccount.ts +++ b/packages/extension/src/ui/features/accountPlugins.tsx/PluginAccount.ts @@ -8,7 +8,7 @@ export class PluginAccount extends ArgentXAccount { constructor(account: ArgentXAccount) { super({ ...account, - type: "argent-plugin", + type: "plugin", contract: new Contract( ArgentPluginCompiledContractAbi as Abi, account.address, diff --git a/packages/extension/src/ui/features/accountTokens/AccountTokens.tsx b/packages/extension/src/ui/features/accountTokens/AccountTokens.tsx index 3cad7e28b..6fac74cef 100644 --- a/packages/extension/src/ui/features/accountTokens/AccountTokens.tsx +++ b/packages/extension/src/ui/features/accountTokens/AccountTokens.tsx @@ -1,26 +1,22 @@ -import { CellStack, DapplandBanner } from "@argent/ui" +import { CellStack, DapplandBanner, Empty, icons } from "@argent/ui" import dapplandBanner from "@argent/ui/assets/dapplandBannerBackground.png" import { Flex, VStack } from "@chakra-ui/react" import { AnimatePresence, motion } from "framer-motion" -import { FC, useCallback, useEffect, useRef } from "react" +import { FC, useCallback, useEffect, useMemo, useRef } from "react" import { useLocation, useNavigate } from "react-router-dom" -import useSWR from "swr" import { useKeyValueStorage } from "../../../shared/storage/hooks" import { userReviewStore } from "../../../shared/userReview" -import { getAccountIdentifier } from "../../../shared/wallet.service" import { routes } from "../../routes" import { redeployAccount } from "../../services/backgroundAccounts" -import { withPolling } from "../../services/swr" import { Account } from "../accounts/Account" -import { - getAccountName, - useAccountMetadata, -} from "../accounts/accountMetadata.state" import { useAccountTransactions } from "../accounts/accountTransactions.state" -import { checkIfUpgradeAvailable } from "../accounts/upgrade.service" -import { useShouldShowNetworkUpgradeMessage } from "../networks/showNetworkUpgrade" -import { useCurrentNetwork } from "../networks/useNetworks" +import { useCheckUpgradeAvailable } from "../accounts/upgrade.service" +import { useIsMultisigDeploying } from "../multisig/hooks/useIsMultisigDeploying" +import { useIsSignerInMultisig } from "../multisig/hooks/useIsSignerInMultisig" +import { useMultisig } from "../multisig/multisig.state" +import { MultisigBanner } from "../multisig/MultisigBanner" +import { useShouldShowNetworkUpgradeMessage } from "../networks/hooks/useShouldShowNetworkUpgradeMessage" import { useBackupRequired } from "../recovery/backupDownload.state" import { RecoveryBanner } from "../recovery/RecoveryBanner" import { EscapeBanner } from "../shield/escape/EscapeBanner" @@ -36,6 +32,8 @@ import { useFeeTokenBalance } from "./tokens.service" import { UpgradeBanner } from "./UpgradeBanner" import { useAccountStatus } from "./useAccountStatus" +const { MultisigIcon } = icons + interface AccountTokensProps { account: Account } @@ -45,7 +43,6 @@ export const AccountTokens: FC = ({ account }) => { const location = useLocation() const status = useAccountStatus(account) const { pendingTransactions } = useAccountTransactions(account) - const { accountNames } = useAccountMetadata() const { isBackupRequired } = useBackupRequired() const { hasSeenBanner } = useDapplandBanner() const currencyDisplayEnabled = useCurrencyDisplayEnabled() @@ -57,8 +54,6 @@ export const AccountTokens: FC = ({ account }) => { const userHasReviewed = useKeyValueStorage(userReviewStore, "hasReviewed") const hasPendingTransactions = pendingTransactions.length > 0 - const accountName = getAccountName(account, accountNames) - const network = useCurrentNetwork() const { shouldShow: shouldShowNetworkUpgradeMessage, updateLastShown: updateLastShownNetworkUpgradeMessage, @@ -74,15 +69,10 @@ export const AccountTokens: FC = ({ account }) => { const { feeTokenBalance } = useFeeTokenBalance(account) - const { data: needsUpgrade = false, mutate } = useSWR( - [ - getAccountIdentifier(account), - network.accountClassHash, - "showUpgradeBanner", - ], - () => checkIfUpgradeAvailable(account, network.accountClassHash), - { suspense: false, ...withPolling(60 * 1000) }, - ) + const { needsUpgrade = false, mutate } = useCheckUpgradeAvailable(account) + + const multisig = useMultisig(account) + const isMultisigDeploying = useIsMultisigDeploying(multisig) const onRedeploy = useCallback(async () => { const data = account.toBaseWalletAccount() @@ -95,25 +85,51 @@ export const AccountTokens: FC = ({ account }) => { }, [account]) const showUpgradeBanner = Boolean( - needsUpgrade && !hasPendingTransactions && feeTokenBalance?.gt(0), + needsUpgrade && + !hasPendingTransactions && + feeTokenBalance?.gt(0) && + !isMultisigDeploying, ) const showNoBalanceForUpgrade = Boolean( needsUpgrade && !hasPendingTransactions && feeTokenBalance?.lte(0), ) - const showBackupBanner = isBackupRequired && !showUpgradeBanner + const showBackupBanner = + isBackupRequired && !showUpgradeBanner && !isMultisigDeploying const hasEscape = accountHasEscape(account) const accountGuardianIsSelf = useAccountGuardianIsSelf(account) + const showAddFundsBackdrop = useMemo(() => { + return multisig?.needsDeploy && feeTokenBalance?.lte(0) + }, [feeTokenBalance, multisig?.needsDeploy]) + + const signerIsInMultisig = useIsSignerInMultisig(multisig) + + const showTokensAndBanners = useMemo(() => { + if (multisig) { + return signerIsInMultisig + } + + return true + }, [multisig, signerIsInMultisig]) + const showDapplandBanner = !hasSeenBanner && !showBackupBanner && !needsUpgrade && !hasPendingTransactions && - !hasEscape + !hasEscape && + !multisig?.needsDeploy const hadPendingTransactions = useRef(false) + + const setDappLandBannerSeen = useCallback(() => { + useDapplandBanner.setState({ + hasSeenBanner: true, + }) + }, []) + useEffect(() => { if (hasPendingTransactions) { hadPendingTransactions.current = true @@ -134,50 +150,76 @@ export const AccountTokens: FC = ({ account }) => { }, [shouldShowNetworkUpgradeMessage]) const tokenListVariant = currencyDisplayEnabled ? "default" : "no-currency" + return ( - - {(hasEscape || accountGuardianIsSelf) && ( - - )} - {showBackupBanner && } - {showUpgradeBanner && ( - + + {showDapplandBanner && ( + + + + )} + + + {(hasEscape || accountGuardianIsSelf) && ( + + )} + {showBackupBanner && } + {showUpgradeBanner && ( + + )} + {showNoBalanceForUpgrade && ( + + )} + {multisig && ( + + )} + {showAddFundsBackdrop && ( + } + title="Add funds to activate multisig" + /> + )} + {!showAddFundsBackdrop && ( + null : undefined} + /> + )} + + ) : ( + } + title="You can no longer use this account" /> )} - {showNoBalanceForUpgrade && ( - - )} - - {showDapplandBanner && ( - - { - useDapplandBanner.setState({ hasSeenBanner: true }) - }} - /> - - )} - - ) diff --git a/packages/extension/src/ui/features/accountTokens/AccountTokensButtons.tsx b/packages/extension/src/ui/features/accountTokens/AccountTokensButtons.tsx index 51bcea13a..165bc28c8 100644 --- a/packages/extension/src/ui/features/accountTokens/AccountTokensButtons.tsx +++ b/packages/extension/src/ui/features/accountTokens/AccountTokensButtons.tsx @@ -1,15 +1,20 @@ import { AlertDialog, Button, icons } from "@argent/ui" -import { Flex, SimpleGrid } from "@chakra-ui/react" +import { Flex, SimpleGrid, useDisclosure } from "@chakra-ui/react" import { FC, useCallback, useMemo, useState } from "react" import { useNavigate } from "react-router-dom" +import { hideMultisig } from "../../../shared/multisig/utils/baseMultisig" import { useAppState } from "../../app.state" import { routes } from "../../routes" import { Account } from "../accounts/Account" +import { autoSelectAccountOnNetwork } from "../accounts/switchAccount" +import { useIsSignerInMultisig } from "../multisig/hooks/useIsSignerInMultisig" +import { useMultisig } from "../multisig/multisig.state" +import { MultisigHideModal } from "../multisig/MultisigDeleteModal" import { useNetworkFeeToken, useTokensWithBalance } from "./tokens.state" import { useAccountIsDeployed } from "./useAccountStatus" -const { AddIcon, SendIcon, PluginIcon } = icons +const { AddIcon, SendIcon, PluginIcon, HideIcon } = icons interface AccountTokensButtonsProps { account: Account @@ -20,6 +25,8 @@ export const AccountTokensButtons: FC = ({ }) => { const navigate = useNavigate() const { switcherNetworkId } = useAppState() + const multisig = useMultisig(account) + const signerIsInMultisig = useIsSignerInMultisig(multisig) const sendToken = useNetworkFeeToken(switcherNetworkId) const { tokenDetails, tokenDetailsIsInitialising } = @@ -68,6 +75,46 @@ export const AccountTokensButtons: FC = ({ : "wait for this account to deploy" } before you can send` + const showSendButton = useMemo(() => { + if (multisig && (multisig.needsDeploy || !signerIsInMultisig)) { + return false + } + + return Boolean(sendToken) + }, [multisig, sendToken, signerIsInMultisig]) + + const showAddFundsButton = useMemo(() => { + if (multisig && !signerIsInMultisig) { + return false + } + + return true + }, [multisig, signerIsInMultisig]) + + const showHideMultisigButton = useMemo(() => { + return multisig && !signerIsInMultisig + }, [multisig, signerIsInMultisig]) + + const { + isOpen: isHideMultisigModalOpen, + onOpen: onHideMultisigModalOpen, + onClose: onHideMultisigModalClose, + } = useDisclosure() + + const onHideConfirm = useCallback(async () => { + if (multisig) { + await hideMultisig(multisig) + const account = await autoSelectAccountOnNetwork(switcherNetworkId) + onHideMultisigModalClose() + if (account) { + navigate(routes.accounts()) + } else { + /** no accounts, return to empty account screen */ + navigate(routes.accountTokens()) + } + } + }, [multisig, navigate, onHideMultisigModalClose, switcherNetworkId]) + return ( = ({ confirmTitle="Add funds" onConfirm={accountIsDeployed ? onAddFunds : undefined} /> - - - {sendToken && ( + + {showAddFundsButton && ( + + )} + {showSendButton && ( )} - {account?.type === "argent-plugin" && ( + {account?.type === "plugin" && ( )} + {showHideMultisigButton && ( + + )} + + ) } diff --git a/packages/extension/src/ui/features/accountTokens/AccountTokensHeader.tsx b/packages/extension/src/ui/features/accountTokens/AccountTokensHeader.tsx index 1ce8be8e4..554d83afa 100644 --- a/packages/extension/src/ui/features/accountTokens/AccountTokensHeader.tsx +++ b/packages/extension/src/ui/features/accountTokens/AccountTokensHeader.tsx @@ -1,5 +1,5 @@ -import { Button, FieldError, H2, icons } from "@argent/ui" -import { VStack } from "@chakra-ui/react" +import { B3, Button, FieldError, H2, icons } from "@argent/ui" +import { Center, VStack } from "@chakra-ui/react" import { FC } from "react" import { prettifyCurrencyValue } from "../../../shared/token/price" @@ -7,6 +7,7 @@ import { BaseWalletAccount } from "../../../shared/wallet.model" import { AddressCopyButton } from "../../components/AddressCopyButton" import { useStarknetId } from "../../services/useStarknetId" import { AccountStatus } from "../accounts/accounts.service" +import { useMultisig } from "../multisig/multisig.state" import { StarknetIdCopyButton } from "./StarknetIdCopyButton" import { useSumTokenBalancesToCurrencyValue } from "./tokenPriceHooks" import { useTokensWithBalance } from "./tokens.state" @@ -29,11 +30,28 @@ export const AccountTokensHeader: FC = ({ const { tokenDetails } = useTokensWithBalance(account) const sumCurrencyValue = useSumTokenBalancesToCurrencyValue(tokenDetails) const accountAddress = account.address + const multisig = useMultisig(account) // This will be undefined if the account is not a multisig + + const { data: starknetId } = useStarknetId(account) const { data: starknetId } = useStarknetId(account) return ( + {multisig && ( +
+ + {multisig.threshold}/{multisig.signers.length} multisig + +
+ )} {sumCurrencyValue !== undefined ? (

{prettifyCurrencyValue(sumCurrencyValue)}

) : ( diff --git a/packages/extension/src/ui/features/accountTokens/ActivateMultisigBanner.tsx b/packages/extension/src/ui/features/accountTokens/ActivateMultisigBanner.tsx new file mode 100644 index 000000000..b41a99717 --- /dev/null +++ b/packages/extension/src/ui/features/accountTokens/ActivateMultisigBanner.tsx @@ -0,0 +1,22 @@ +import { AlertButton, icons } from "@argent/ui" +import { FC } from "react" + +const { MultisigIcon } = icons + +export interface ActivateMultisigBannerProps { + onClick: () => void +} + +export const ActivateMultisigBanner: FC = ({ + onClick, +}) => ( + } + colorScheme="primary" + bg="primaryExtraDark.500" + onClick={onClick} + /> +) diff --git a/packages/extension/src/ui/features/accountTokens/ExportPrivateKeyScreen.tsx b/packages/extension/src/ui/features/accountTokens/ExportPrivateKeyScreen.tsx index c04be66fe..9ada8b65a 100644 --- a/packages/extension/src/ui/features/accountTokens/ExportPrivateKeyScreen.tsx +++ b/packages/extension/src/ui/features/accountTokens/ExportPrivateKeyScreen.tsx @@ -11,7 +11,7 @@ import { checkPassword } from "../../services/backgroundSessions" import { H2 } from "../../theme/Typography" import { StickyGroup } from "../actions/DeprecatedConfirmScreen" import { PasswordForm } from "../lock/PasswordForm" -import { useCurrentNetwork } from "../networks/useNetworks" +import { useCurrentNetwork } from "../networks/hooks/useCurrentNetwork" import { StatusMessageBanner } from "../statusMessage/StatusMessageBanner" import { usePrivateKey } from "./usePrivateKey" diff --git a/packages/extension/src/ui/features/accountTokens/SendTokenScreen.tsx b/packages/extension/src/ui/features/accountTokens/SendTokenScreen.tsx index a485f3705..edc1d9520 100644 --- a/packages/extension/src/ui/features/accountTokens/SendTokenScreen.tsx +++ b/packages/extension/src/ui/features/accountTokens/SendTokenScreen.tsx @@ -12,6 +12,7 @@ import { prettifyCurrencyValue, prettifyTokenBalance, } from "../../../shared/token/price" +import { WalletAccount } from "../../../shared/wallet.model" import { AddContactBottomSheet } from "../../components/AddContactBottomSheet" import { Button, ButtonTransparent } from "../../components/Button" import Column, { ColumnCenter } from "../../components/Column" @@ -43,16 +44,11 @@ import { import { useOnClickOutside } from "../../services/useOnClickOutside" import { getAddressFromStarkName } from "../../services/useStarknetId" import { H3, H5 } from "../../theme/Typography" -import { Account } from "../accounts/Account" -import { - getAccountName, - useAccountMetadata, -} from "../accounts/accountMetadata.state" +import { AccountAvatar } from "../accounts/AccountAvatar" import { getAccountImageUrl } from "../accounts/accounts.service" import { useSelectedAccount } from "../accounts/accounts.state" import { AddressBookMenu } from "../accounts/AddressBookMenu" -import { ProfilePicture } from "../accounts/ProfilePicture" -import { useCurrentNetwork } from "../networks/useNetworks" +import { useCurrentNetwork } from "../networks/hooks/useCurrentNetwork" import { useYupValidationResolver } from "../settings/useYupValidationResolver" import { TokenIcon } from "./TokenIcon" import { TokenMenuDeprecated } from "./TokenMenuDeprecated" @@ -216,21 +212,12 @@ export const SendTokenScreen: FC = () => { const feeToken = useNetworkFeeToken(account?.networkId) const [maxClicked, setMaxClicked] = useState(false) const [addressBookRecipient, setAddressBookRecipient] = useState< - Account | AddressBookContact + WalletAccount | AddressBookContact >() const [bottomSheetOpen, setBottomSheetOpen] = useState(false) - const { accountNames } = useAccountMetadata() const [starknetIdLoading, setStarknetIdLoading] = useState(false) - const accountName = useMemo( - () => - addressBookRecipient - ? "name" in addressBookRecipient - ? addressBookRecipient.name - : getAccountName(addressBookRecipient, accountNames) - : undefined, - [accountNames, addressBookRecipient], - ) + const accountName = addressBookRecipient?.name const { id: currentNetworkId } = useCurrentNetwork() @@ -368,7 +355,9 @@ export const SendTokenScreen: FC = () => { setMaxInputAmount(token, maxFee) } - const handleAddressSelect = (account?: Account | AddressBookContact) => { + const handleAddressSelect = ( + account?: WalletAccount | AddressBookContact, + ) => { if (!account) { return } @@ -511,20 +500,20 @@ export const SendTokenScreen: FC = () => {
+ {/** TODO: refactor - same pattern used in SendNftScreen */} {addressBookRecipient && accountName ? ( setAddressBookRecipient(undefined)} > - -
{accountName}
diff --git a/packages/extension/src/ui/features/accountTokens/TokenIcon.tsx b/packages/extension/src/ui/features/accountTokens/TokenIcon.tsx index cfe019ead..27bf680c6 100644 --- a/packages/extension/src/ui/features/accountTokens/TokenIcon.tsx +++ b/packages/extension/src/ui/features/accountTokens/TokenIcon.tsx @@ -1,12 +1,10 @@ -import { Circle, Image } from "@chakra-ui/react" -import { ComponentProps, FC } from "react" +import { Circle, Image, ImageProps, SquareProps } from "@chakra-ui/react" +import { FC } from "react" import { generateAvatarImage } from "../../../shared/avatarImage" import { getColor } from "../accounts/accounts.service" -export interface TokenIconProps - extends Pick, "size">, - ComponentProps { +export interface TokenIconProps extends Pick, ImageProps { name: string url?: string } diff --git a/packages/extension/src/ui/features/accountTokens/TokenList.tsx b/packages/extension/src/ui/features/accountTokens/TokenList.tsx index 1702834b2..dc156ce82 100644 --- a/packages/extension/src/ui/features/accountTokens/TokenList.tsx +++ b/packages/extension/src/ui/features/accountTokens/TokenList.tsx @@ -18,6 +18,7 @@ interface TokenListProps { showTokenSymbol?: boolean variant?: TokenListItemVariant navigateToSend?: boolean + onItemClick?: () => void } export const TokenList: FC = ({ @@ -26,6 +27,7 @@ export const TokenList: FC = ({ showTokenSymbol = false, variant, navigateToSend = false, + onItemClick, }) => { const navigate = useNavigate() const account = useSelectedAccount() @@ -54,6 +56,10 @@ export const TokenList: FC = ({ variant={variant} showTokenSymbol={showTokenSymbol} onClick={() => { + if (onItemClick) { + return onItemClick() + } + navigate( navigateToSend ? routes.sendToken(token.address, returnTo) diff --git a/packages/extension/src/ui/features/accountTokens/TokenListItem.tsx b/packages/extension/src/ui/features/accountTokens/TokenListItem.tsx index ed7e0f61e..6a33542f9 100644 --- a/packages/extension/src/ui/features/accountTokens/TokenListItem.tsx +++ b/packages/extension/src/ui/features/accountTokens/TokenListItem.tsx @@ -1,6 +1,6 @@ -import { Button, FieldError, H6, P4, TextWithAmount, icons } from "@argent/ui" -import { Flex, Skeleton, Tooltip } from "@chakra-ui/react" -import { ComponentProps, FC } from "react" +import { FieldError, H6, P4, TextWithAmount, icons } from "@argent/ui" +import { ButtonProps, Flex, Skeleton, Tooltip } from "@chakra-ui/react" +import { FC } from "react" import { prettifyCurrencyValue, @@ -16,7 +16,7 @@ const { AlertIcon } = icons export type TokenListItemVariant = "default" | "no-currency" -export interface TokenListItemProps extends ComponentProps { +export interface TokenListItemProps extends ButtonProps { token: TokenDetailsWithBalance variant?: TokenListItemVariant isLoading?: boolean diff --git a/packages/extension/src/ui/features/accountTokens/TokenMenu.tsx b/packages/extension/src/ui/features/accountTokens/TokenMenu.tsx index 92844b073..c62cd89d4 100644 --- a/packages/extension/src/ui/features/accountTokens/TokenMenu.tsx +++ b/packages/extension/src/ui/features/accountTokens/TokenMenu.tsx @@ -12,7 +12,7 @@ import { openBlockExplorerAddress, useBlockExplorerTitle, } from "../../services/blockExplorer.service" -import { useCurrentNetwork } from "../networks/useNetworks" +import { useCurrentNetwork } from "../networks/hooks/useCurrentNetwork" const { MoreIcon } = icons diff --git a/packages/extension/src/ui/features/accountTokens/TokenMenuDeprecated.tsx b/packages/extension/src/ui/features/accountTokens/TokenMenuDeprecated.tsx index 4320f0db4..2c3c5a92f 100644 --- a/packages/extension/src/ui/features/accountTokens/TokenMenuDeprecated.tsx +++ b/packages/extension/src/ui/features/accountTokens/TokenMenuDeprecated.tsx @@ -1,9 +1,8 @@ import { FC, useRef, useState } from "react" -import CopyToClipboard from "react-copy-to-clipboard" import { useNavigate } from "react-router-dom" import styled from "styled-components" -import { ContentCopyIcon, VisibilityOff } from "../../components/Icons/MuiIcons" +import { VisibilityOff } from "../../components/Icons/MuiIcons" import { MoreVertSharp } from "../../components/Icons/MuiIcons" import { ViewOnBlockExplorerIcon } from "../../components/Icons/ViewOnBlockExplorerIcon" import { @@ -16,13 +15,12 @@ import { } from "../../components/Menu" import Row, { RowCentered } from "../../components/Row" import { routes } from "../../routes" -import { normalizeAddress } from "../../services/addresses" import { openBlockExplorerAddress, useBlockExplorerTitle, } from "../../services/blockExplorer.service" import { useOnClickOutside } from "../../services/useOnClickOutside" -import { useCurrentNetwork } from "../networks/useNetworks" +import { useCurrentNetwork } from "../networks/hooks/useCurrentNetwork" const StyledMenuContainer = styled(MenuContainer)` flex: 1; @@ -69,7 +67,7 @@ export const TokenMenuDeprecated: FC = ({
{isMenuOpen && ( - setMenuOpen(false)} > @@ -80,7 +78,7 @@ export const TokenMenuDeprecated: FC = ({ - + */} openBlockExplorerAddress(currentNetwork, tokenAddress) diff --git a/packages/extension/src/ui/features/accountTokens/dappland/banner.state.ts b/packages/extension/src/ui/features/accountTokens/dappland/banner.state.ts index d55083073..f1f13e0a2 100644 --- a/packages/extension/src/ui/features/accountTokens/dappland/banner.state.ts +++ b/packages/extension/src/ui/features/accountTokens/dappland/banner.state.ts @@ -1,11 +1,11 @@ -import create from "zustand" +import { create } from "zustand" import { persist } from "zustand/middleware" export interface DapplandBannerState { hasSeenBanner: boolean } -export const useDapplandBanner = create( +export const useDapplandBanner = create()( persist( (_set, _get) => ({ hasSeenBanner: false, diff --git a/packages/extension/src/ui/features/accountTokens/tokenPriceHooks.ts b/packages/extension/src/ui/features/accountTokens/tokenPriceHooks.ts index b20145b5d..8fea5482b 100644 --- a/packages/extension/src/ui/features/accountTokens/tokenPriceHooks.ts +++ b/packages/extension/src/ui/features/accountTokens/tokenPriceHooks.ts @@ -24,7 +24,7 @@ import { Token } from "../../../shared/token/type" import { isNumeric } from "../../../shared/utils/number" import { argentApiFetcher } from "../../services/argentApiFetcher" import { useConditionallyEnabledSWR, withPolling } from "../../services/swr" -import { useIsMainnet } from "../networks/useNetworks" +import { useIsMainnet } from "../networks/hooks/useIsMainnet" import { TokenDetailsWithBalance } from "./tokens.state" /** @returns true if API is enabled, app is on mainnet and the user has enabled Argent services */ diff --git a/packages/extension/src/ui/features/accountTokens/tokens.service.ts b/packages/extension/src/ui/features/accountTokens/tokens.service.ts index 5ba1d4ac2..a28123bf8 100644 --- a/packages/extension/src/ui/features/accountTokens/tokens.service.ts +++ b/packages/extension/src/ui/features/accountTokens/tokens.service.ts @@ -9,6 +9,7 @@ import { getMulticallForNetwork } from "../../../shared/multicall" import { getTokenBalanceForAccount } from "../../../shared/token/getTokenBalance" import { Token } from "../../../shared/token/type" import { getFeeToken } from "../../../shared/token/utils" +import { BaseWalletAccount } from "../../../shared/wallet.model" import { getAccountIdentifier } from "../../../shared/wallet.service" import { Account } from "../accounts/Account" import { TokenDetailsWithBalance, getNetworkFeeToken } from "./tokens.state" @@ -117,7 +118,7 @@ export type BalancesMap = Record export const fetchAllTokensBalance = async ( tokenAddresses: string[], - account: Account, + account: BaseWalletAccount, ) => { const response = await Promise.allSettled( tokenAddresses.map((tokenAddress) => { diff --git a/packages/extension/src/ui/features/accountTokens/usePrivateKey.ts b/packages/extension/src/ui/features/accountTokens/usePrivateKey.ts index c4bd60e2e..786b008f3 100644 --- a/packages/extension/src/ui/features/accountTokens/usePrivateKey.ts +++ b/packages/extension/src/ui/features/accountTokens/usePrivateKey.ts @@ -1,7 +1,8 @@ import { useCallback, useEffect, useState } from "react" import { getPrivateKey } from "../../services/backgroundAccounts" -import { useSelectedAccount } from "../accounts/accounts.state" +import { selectedAccountView } from "../../views/account" +import { useView } from "../../views/implementation/react" export const usePrivateKey = (address?: string, networkId?: string) => { const [privateKey, setPrivateKey] = useState() @@ -19,6 +20,6 @@ export const usePrivateKey = (address?: string, networkId?: string) => { } export const usePrivateKeyForSelectedAccount = () => { - const account = useSelectedAccount() + const account = useView(selectedAccountView) return usePrivateKey(account?.address, account?.networkId) } diff --git a/packages/extension/src/ui/features/accountTokens/useTransactionStatus.ts b/packages/extension/src/ui/features/accountTokens/useTransactionStatus.ts index 51121a241..e908efd5e 100644 --- a/packages/extension/src/ui/features/accountTokens/useTransactionStatus.ts +++ b/packages/extension/src/ui/features/accountTokens/useTransactionStatus.ts @@ -1,20 +1,24 @@ import { memoize } from "lodash-es" import { useMemo } from "react" -import { Status as StarkNetStatus } from "starknet" import { transactionsStore } from "../../../background/transactions/store" import { useArrayStorage } from "../../../shared/storage/hooks" -import { Transaction } from "../../../shared/transactions" +import { + ExtendedTransactionStatus, + Transaction, +} from "../../../shared/transactions" -function transformStatus(status: StarkNetStatus): Status { +function transformStatus(status: ExtendedTransactionStatus): Status { return ["ACCEPTED_ON_L1", "ACCEPTED_ON_L2", "PENDING"].includes(status) ? "SUCCESS" : status === "REJECTED" ? "ERROR" + : status === "CANCELLED" + ? "CANCELLED" : "PENDING" } -type Status = "UNKNOWN" | "PENDING" | "SUCCESS" | "ERROR" +type Status = "UNKNOWN" | "PENDING" | "SUCCESS" | "ERROR" | "CANCELLED" const transactionSelector = memoize( (hash?: string, networkId?: string) => (transaction: Transaction) => diff --git a/packages/extension/src/ui/features/accounts/Account.ts b/packages/extension/src/ui/features/accounts/Account.ts index b1fa530bb..19c2c94ef 100644 --- a/packages/extension/src/ui/features/accounts/Account.ts +++ b/packages/extension/src/ui/features/accounts/Account.ts @@ -8,13 +8,15 @@ import { Network, getNetwork, getProvider } from "../../../shared/network" import { ArgentAccountType, BaseWalletAccount, + CreateAccountType, WalletAccount, WalletAccountSigner, } from "../../../shared/wallet.model" import { getAccountIdentifier } from "../../../shared/wallet.service" -import { createNewAccount } from "../../services/backgroundAccounts" +import { clientAccountService } from "../../services/account" export interface AccountConstructorProps { + name: string address: string network: Network signer: WalletAccountSigner @@ -28,6 +30,7 @@ export interface AccountConstructorProps { } export class Account { + name: string address: string network: Network networkId: string @@ -43,6 +46,7 @@ export class Account { needsDeploy?: boolean constructor({ + name, address, network, signer, @@ -54,6 +58,7 @@ export class Account { needsDeploy = false, contract, }: AccountConstructorProps) { + this.name = name this.address = address this.network = network this.networkId = @@ -102,7 +107,7 @@ export class Account { public async getCurrentImplementation(): Promise { if (this.needsDeploy) { - return this.network.accountClassHash?.argentAccount // cuz we always deploy regular accounts + return this.network.accountClassHash?.[this.type] // We deploy Standard and Multisig accounts now } const multicall = getMulticallForNetwork(this.network) @@ -114,12 +119,11 @@ export class Account { return stark.makeAddress(number.toHex(number.toBN(implementation))) } - public static async create(networkId: string): Promise { - const result = await createNewAccount(networkId) - if (result === "error") { - throw new Error(result) - } - + public static async create( + networkId: string, + type?: CreateAccountType, + ): Promise { + const account = await clientAccountService.createAccount(networkId, type) const network = await getNetwork(networkId) if (!network) { @@ -127,18 +131,20 @@ export class Account { } return new Account({ - address: result.account.address, + name: account.name, + address: account.address, network, - signer: result.account.signer, - type: result.account.type, - guardian: result.account.guardian, - escape: result.account.escape, - needsDeploy: result.account.needsDeploy, + signer: account.signer, + type: account.type, + guardian: account.guardian, + escape: account.escape, + needsDeploy: account.needsDeploy, }) } public toWalletAccount(): WalletAccount { const { + name, networkId, address, network, @@ -149,6 +155,7 @@ export class Account { needsDeploy, } = this return { + name, networkId, address, network, diff --git a/packages/extension/src/ui/features/accounts/AccountAvatar.tsx b/packages/extension/src/ui/features/accounts/AccountAvatar.tsx new file mode 100644 index 000000000..4d89170e5 --- /dev/null +++ b/packages/extension/src/ui/features/accounts/AccountAvatar.tsx @@ -0,0 +1,40 @@ +import { Circle, Flex, Image, ImageProps, SquareProps } from "@chakra-ui/react" +import { FC } from "react" + +export interface AccountAvatarProps + extends ImageProps, + Pick { + outlined?: boolean +} + +export const AccountAvatar: FC = ({ + size = 12, + outlined, + children, + ...rest +}) => { + return ( + + + {outlined && ( + <> + + + + )} + {children} + + ) +} diff --git a/packages/extension/src/ui/features/accounts/AccountContainer.tsx b/packages/extension/src/ui/features/accounts/AccountContainer.tsx index 3e18e295b..4bfdec5bc 100644 --- a/packages/extension/src/ui/features/accounts/AccountContainer.tsx +++ b/packages/extension/src/ui/features/accounts/AccountContainer.tsx @@ -1,73 +1,131 @@ import { + L1, ScrollContainer, Tab, TabBar, icons, useScrollRestoration, } from "@argent/ui" -import { FC, PropsWithChildren } from "react" +import { Center } from "@chakra-ui/react" +import { ComponentProps, FC, PropsWithChildren } from "react" import { NavLink } from "react-router-dom" import { routes } from "../../routes" +import { selectedAccountView } from "../../views/account" +import { useView } from "../../views/implementation/react" +import { useIsSignerInMultisig } from "../multisig/hooks/useIsSignerInMultisig" +import { useMultisig } from "../multisig/multisig.state" +import { useMultisigPendingTransactionsAwaitingConfirmation } from "../multisig/multisigTransactions.state" import { WithEscapeWarning } from "../shield/escape/WithEscapeWarning" -import { AccountNavigationBar } from "./AccountNavigationBar" -import { useSelectedAccount } from "./accounts.state" +import { AccountNavigationBarContainer } from "./AccountNavigationBarContainer" import { useAccountTransactions } from "./accountTransactions.state" const { WalletIcon, NftIcon, ActivityIcon, SwapIcon } = icons -export interface AccountContainerProps extends PropsWithChildren { +/** TODO: refactor: rename 'RootTabsContainer' or similar and extract 'RootTabs' component */ + +interface AccountContainerProps extends PropsWithChildren { scrollKey: string } -export const AccountContainer: FC = ({ - scrollKey, - children, -}) => { - const account = useSelectedAccount() +export const AccountContainer: FC = (props) => { + const account = useView(selectedAccountView) + // TODO: refactor multisig to use services and views + const multisig = useMultisig(account) + const signerIsInMultisig = useIsSignerInMultisig(multisig) + + // TODO: refactor activity/transactions to use services and views const { pendingTransactions } = useAccountTransactions(account) - const { scrollRef, scroll } = useScrollRestoration(scrollKey) + const pendingMultisigTransactions = + useMultisigPendingTransactionsAwaitingConfirmation(account) + + const showActivateBanner = Boolean(multisig?.needsDeploy) // False if multisig is undefined + + const showSignerIsRemovedBanner = !signerIsInMultisig + const showMultisigBanner = + multisig && (showActivateBanner || showSignerIsRemovedBanner) if (!account) { - return <> + return null } + const totalPendingTransactions = + pendingTransactions.length + pendingMultisigTransactions.length + + return ( + + ) +} + +export interface RootTabsProps extends PropsWithChildren { + scrollKey: string + activityBadgeLabel: ComponentProps["badgeLabel"] + showMultisigBanner?: boolean + showActivateBanner: boolean +} + +export const RootTabs: FC = ({ + activityBadgeLabel, + scrollKey, + showMultisigBanner, + showActivateBanner, + children, +}) => { + const { scrollRef, scroll } = useScrollRestoration(scrollKey) + return ( - + {children} - - } - label="Tokens" - /> - } - label="NFTs" - /> - } - label="Swap" - /> - } - badgeLabel={pendingTransactions.length} - badgeDescription={"Pending transactions"} - label="Activity" - /> - + {showMultisigBanner ? ( +
+ + {showActivateBanner ? ( + <>Not activated + ) : ( + <>You were removed from this multisig + )} + +
+ ) : ( + + } + label="Tokens" + /> + } + label="NFTs" + /> + } + label="Swap" + /> + } + badgeLabel={activityBadgeLabel} + badgeDescription={"Pending transactions"} + label="Activity" + /> + + )}
) } diff --git a/packages/extension/src/ui/features/accounts/AccountLabel.tsx b/packages/extension/src/ui/features/accounts/AccountLabel.tsx new file mode 100644 index 000000000..b55d1371f --- /dev/null +++ b/packages/extension/src/ui/features/accounts/AccountLabel.tsx @@ -0,0 +1,41 @@ +import { L2 } from "@argent/ui" +import { FC } from "react" + +import { ArgentAccountType } from "../../../shared/wallet.model" + +export interface AccountLabelProps { + accountType: ArgentAccountType +} + +export const AccountLabel: FC = ({ accountType }) => { + let label: string | null = null + + switch (accountType) { + case "plugin": + label = "Plugin" + break + + case "betterMulticall": + label = "Better MC" + break + + default: + break + } + + return label ? ( + + {label} + + ) : null +} diff --git a/packages/extension/src/ui/features/accounts/AccountListHiddenScreen.test.tsx b/packages/extension/src/ui/features/accounts/AccountListHiddenScreen.test.tsx new file mode 100644 index 000000000..2dd460141 --- /dev/null +++ b/packages/extension/src/ui/features/accounts/AccountListHiddenScreen.test.tsx @@ -0,0 +1,50 @@ +import { fireEvent, screen } from "@testing-library/react" +import { describe, expect, it } from "vitest" + +import { PendingMultisig } from "../../../shared/multisig/types" +import { renderWithLegacyProviders } from "../../test/utils" +import { Account } from "./Account" +import { AccountListHiddenScreen } from "./AccountListHiddenScreen" + +describe("AccountListHiddenScreen", () => { + it("Calls expected method when account is clicked", async () => { + const onUnhideAccount = vi.fn() + const onUnhidePendingMultisig = vi.fn() + const onBack = vi.fn() + + const hiddenAccounts = [ + { + name: "Account 1 Lorem Ipsum Dolor Sit Amet", + address: "0x123", + networkId: "goerli-alpha", + }, + ] as Account[] + + const hiddenPendingMultisigAccounts = [ + { + name: "Multi Sig", + type: "multisig", + hidden: true, + publicKey: "0xabc", + }, + ] as PendingMultisig[] + + renderWithLegacyProviders( + , + ) + + fireEvent.click(screen.getByText(/^Account 1/)) + expect(onUnhideAccount).toHaveBeenCalledWith(hiddenAccounts[0]) + + fireEvent.click(screen.getByText(/^Multi Sig/)) + expect(onUnhidePendingMultisig).toHaveBeenCalledWith( + hiddenPendingMultisigAccounts[0], + ) + }) +}) diff --git a/packages/extension/src/ui/features/accounts/AccountListHiddenScreen.tsx b/packages/extension/src/ui/features/accounts/AccountListHiddenScreen.tsx index 6ed9139b9..49c5ec5e6 100644 --- a/packages/extension/src/ui/features/accounts/AccountListHiddenScreen.tsx +++ b/packages/extension/src/ui/features/accounts/AccountListHiddenScreen.tsx @@ -1,42 +1,50 @@ import { BarBackButton, CellStack, NavigationContainer } from "@argent/ui" -import { FC } from "react" -import { Navigate, useNavigate, useParams } from "react-router-dom" +import { FC, ReactEventHandler } from "react" -import { useAppState } from "../../app.state" -import { routes, useReturnTo } from "../../routes" -import { AccountListHiddenScreenItem } from "./AccountListHiddenScreenItem" -import { isHiddenAccount, useAccountsOnNetwork } from "./accounts.state" +import { PendingMultisig } from "../../../shared/multisig/types" +import { WalletAccount } from "../../../shared/wallet.model" +import { PendingMultisigListItem } from "../multisig/PendingMultisigListItem" +import { AccountListItem } from "./AccountListItem" -export const AccountListHiddenScreen: FC = () => { - const { networkId } = useParams() - const { switcherNetworkId } = useAppState() - const navigate = useNavigate() - - const hiddenAccounts = useAccountsOnNetwork({ - showHidden: true, - networkId: networkId ?? switcherNetworkId, - }).filter(isHiddenAccount) - - const returnTo = useReturnTo() +interface AccountListHiddenScreenProps { + onBack: ReactEventHandler + hiddenAccounts: WalletAccount[] + hiddenPendingMultisigAccounts: PendingMultisig[] + onUnhideAccount: (account: WalletAccount) => void + onUnhidePendingMultisig: (pendingMultisig: PendingMultisig) => void +} - const hasHiddenAccounts = hiddenAccounts.length > 0 - if (!hasHiddenAccounts) { - return - } +export const AccountListHiddenScreen: FC = ({ + onBack, + hiddenAccounts = [], + hiddenPendingMultisigAccounts = [], + onUnhideAccount, + onUnhidePendingMultisig, +}) => { return ( navigate(returnTo ? returnTo : routes.accounts())} - /> - } + leftButton={} > {hiddenAccounts.map((account) => ( - diff --git a/packages/extension/src/ui/features/accounts/AccountListHiddenScreenContainer.tsx b/packages/extension/src/ui/features/accounts/AccountListHiddenScreenContainer.tsx new file mode 100644 index 000000000..8019bc6f7 --- /dev/null +++ b/packages/extension/src/ui/features/accounts/AccountListHiddenScreenContainer.tsx @@ -0,0 +1,66 @@ +import { FC, useCallback, useEffect } from "react" +import { useParams } from "react-router-dom" + +import { accountService } from "../../../shared/account/service" +import { PendingMultisig } from "../../../shared/multisig/types" +import { unhidePendingMultisig } from "../../../shared/multisig/utils/pendingMultisig" +import { WalletAccount } from "../../../shared/wallet.model" +import { useAppState } from "../../app.state" +import { useNavigateReturnToOr } from "../../hooks/useNavigateReturnTo" +import { routes } from "../../routes" +import { hiddenAccountsOnNetworkFamily } from "../../views/account" +import { useView } from "../../views/implementation/react" +import { + isHiddenPendingMultisig, + usePendingMultisigsOnNetwork, +} from "../multisig/multisig.state" +import { AccountListHiddenScreen } from "./AccountListHiddenScreen" + +export const AccountListHiddenScreenContainer: FC = () => { + const { networkId } = useParams() + // TODO: refactor to use view as soon as networks are using views + const { switcherNetworkId } = useAppState() + const navigateReturnTo = useNavigateReturnToOr(routes.accounts()) + + const hiddenAccounts = useView( + hiddenAccountsOnNetworkFamily(networkId ?? switcherNetworkId), + ) + + const hiddenPendingMultisigAccounts = usePendingMultisigsOnNetwork({ + showHidden: true, + networkId: networkId ?? switcherNetworkId, + }).filter(isHiddenPendingMultisig) + + useEffect(() => { + const hasHiddenAccounts = + hiddenAccounts.length > 0 || hiddenPendingMultisigAccounts.length > 0 + if (!hasHiddenAccounts) { + navigateReturnTo() + } + }, [ + hiddenAccounts.length, + hiddenPendingMultisigAccounts.length, + navigateReturnTo, + ]) + + const onUnhideAccount = useCallback((account: WalletAccount) => { + void accountService.setHide(false, account) + }, []) + + const onUnhidePendingMultisig = useCallback( + (pendingMultisig: PendingMultisig) => { + void unhidePendingMultisig(pendingMultisig) // TODO: use service. Skipped for now, as multisig is not yet stable enough to do refactor on. + }, + [], + ) + + return ( + + ) +} diff --git a/packages/extension/src/ui/features/accounts/AccountListHiddenScreenItem.tsx b/packages/extension/src/ui/features/accounts/AccountListHiddenScreenItem.tsx deleted file mode 100644 index 360d2b26c..000000000 --- a/packages/extension/src/ui/features/accounts/AccountListHiddenScreenItem.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { FC } from "react" - -import { unhideAccount } from "../../../shared/account/store" -import { makeClickable } from "../../services/a11y" -import { Account } from "./Account" -import { AccountListItem } from "./AccountListItem" -import { getAccountName, useAccountMetadata } from "./accountMetadata.state" - -interface IAccountListHiddenScreenItem { - account: Account -} - -export const AccountListHiddenScreenItem: FC = ({ - account, -}) => { - const { accountNames } = useAccountMetadata() - const accountName = getAccountName(account, accountNames) - return ( - { - // update the state in the wallet - await unhideAccount(account) - })} - accountName={accountName} - accountAddress={account.address} - networkId={account.networkId} - accountType={account.type} - hidden - /> - ) -} diff --git a/packages/extension/src/ui/features/accounts/AccountListItem.tsx b/packages/extension/src/ui/features/accounts/AccountListItem.tsx index 725748b4f..f3fe2ee5b 100644 --- a/packages/extension/src/ui/features/accounts/AccountListItem.tsx +++ b/packages/extension/src/ui/features/accounts/AccountListItem.tsx @@ -1,6 +1,6 @@ -import { H6, L2, P4, icons, typographyStyles } from "@argent/ui" -import { Circle, Flex, Image, Text, Tooltip, chakra } from "@chakra-ui/react" -import { ComponentProps, FC } from "react" +import { H6, P4, icons, typographyStyles } from "@argent/ui" +import { Circle, Flex, Text, Tooltip, chakra } from "@chakra-ui/react" +import { FC } from "react" import { ArgentAccountType } from "../../../shared/wallet.model" import { @@ -9,12 +9,13 @@ import { } from "../../components/CustomButtonCell" import { TransactionStatusIndicator } from "../../components/StatusIndicator" import { formatTruncatedAddress } from "../../services/addresses" -import { getEscapeDisplayAttributes } from "../shield/escape/EscapeBanner" -import { useLiveAccountEscape } from "../shield/escape/useAccountEscape" +import { AccountAvatar } from "./AccountAvatar" +import { AccountLabel } from "./AccountLabel" +import { AccountListItemShieldBadgeContainer } from "./AccountListItemShieldBadgeContainer" +import { AccountListItemUpgradeBadge } from "./AccountListItemUpgradeBadge" import { getNetworkAccountImageUrl } from "./accounts.service" -import { useAccount } from "./accounts.state" -const { LinkIcon, ViewIcon, UpgradeIcon, ArgentShieldIcon } = icons +const { LinkIcon, ViewIcon } = icons export interface AccountListItemProps extends CustomButtonCellProps { accountName: string @@ -27,42 +28,9 @@ export interface AccountListItemProps extends CustomButtonCellProps { connectedHost?: string hidden?: boolean avatarOutlined?: boolean + avatarSize?: number isShield?: boolean -} - -interface AccountAvatarProps extends ComponentProps<"img"> { - outlined?: boolean -} - -export const AccountAvatar: FC = ({ - outlined, - children, - ...rest -}) => { - return ( - - - {outlined && ( - <> - - - - )} - {children} - - ) + isRemovedFromMultisig?: boolean } const NetworkStatusWrapper = chakra(Flex, { @@ -76,71 +44,6 @@ const NetworkStatusWrapper = chakra(Flex, { }, }) -export const AccountListItemUpgradeBadge: FC = () => ( - - - - - -) - -type AccountListItemShieldBadgeProps = Pick< - AccountListItemProps, - "accountAddress" | "networkId" -> - -export const AccountListItemShieldBadge: FC< - AccountListItemShieldBadgeProps -> = ({ accountAddress, networkId }) => { - const account = useAccount({ address: accountAddress, networkId }) - const liveAccountEscape = useLiveAccountEscape(account) - if (liveAccountEscape) { - const { colorScheme, title } = getEscapeDisplayAttributes(liveAccountEscape) - return ( - - - - - - ) - } - return ( - - - - - - ) -} - export const AccountListItem: FC = ({ accountName, accountAddress, @@ -153,13 +56,15 @@ export const AccountListItem: FC = ({ connectedHost, hidden, avatarOutlined, + avatarSize, + isRemovedFromMultisig, children, ...rest }) => { const avatarBadge = upgrade ? ( ) : isShield ? ( - @@ -168,11 +73,12 @@ export const AccountListItem: FC = ({

{targetedDappWebsite && ( diff --git a/packages/extension/src/ui/features/actions/transaction/ApproveTransactionScreen/MultisigBanner.tsx b/packages/extension/src/ui/features/actions/transaction/ApproveTransactionScreen/MultisigBanner.tsx new file mode 100644 index 000000000..3b21ee1e9 --- /dev/null +++ b/packages/extension/src/ui/features/actions/transaction/ApproveTransactionScreen/MultisigBanner.tsx @@ -0,0 +1,68 @@ +import { P4, SplitProgress, icons } from "@argent/ui" +import { Box, Flex, Progress } from "@chakra-ui/react" + +import { Account } from "../../../accounts/Account" +import { useMultisig } from "../../../multisig/multisig.state" + +const { MultisigIcon, ChevronRightIcon } = icons + +export const MultisigBanner = ({ + confirmations = 0, + account, + onClick, +}: { + confirmations?: number + account: Account + onClick?: () => void +}) => { + const multisig = useMultisig(account) + return ( + + + + + + Confirmations: {confirmations} + + + + {multisig?.threshold && ( + + {multisig.threshold - confirmations} more required + + )} + {onClick ? ( + + ) : ( + + )} + + + {multisig?.threshold && + (multisig?.threshold > 10 ? ( + + ) : ( + + ))} + + ) +} diff --git a/packages/extension/src/ui/features/actions/transaction/ApproveTransactionScreen/TransactionActions.tsx b/packages/extension/src/ui/features/actions/transaction/ApproveTransactionScreen/TransactionActions.tsx index 9690ccaf5..b6210a5aa 100644 --- a/packages/extension/src/ui/features/actions/transaction/ApproveTransactionScreen/TransactionActions.tsx +++ b/packages/extension/src/ui/features/actions/transaction/ApproveTransactionScreen/TransactionActions.tsx @@ -8,49 +8,102 @@ import { } from "@argent/ui" import { Box } from "@chakra-ui/react" import { FC } from "react" -import { Call, number } from "starknet" +import { number } from "starknet" import { entryPointToHumanReadable } from "../../../../../shared/transactions" import { formatTruncatedAddress } from "../../../../services/addresses" +import { TransactionActionsType } from "../types" export interface TransactionActionsProps { - transactions: Call[] + action: TransactionActionsType } -export const TransactionActions: FC = ({ - transactions, -}) => { +export const TransactionActions: FC = ({ action }) => { return ( Actions - {transactions.map((transaction, txIndex) => ( + {/** Render Activate Account / Multisig Action*/} + {action.type === "DEPLOY_ACCOUNT" && ( - {transaction.calldata?.map((calldata, cdIndex) => ( + {action.payload.classHash && ( - ))} + )} - ))} + )} + + {/** Render Add Argent Shield */} + {action.type === "ADD_ARGENT_SHIELD" && ( + + + + + )} + + {/** Render Add Argent Shield */} + {action.type === "REMOVE_ARGENT_SHIELD" && ( + + + + + )} + + {/** Render INVOKE_FUNCTION Calls */} + {action.type === "INVOKE_FUNCTION" && + action.payload.map((transaction, txIndex) => ( + + + + {transaction.calldata?.map((calldata, cdIndex) => ( + + ))} + + + ))} ) diff --git a/packages/extension/src/ui/features/actions/transaction/ApproveTransactionScreen/TransactionBanner.tsx b/packages/extension/src/ui/features/actions/transaction/ApproveTransactionScreen/TransactionBanner.tsx index cef021d38..965fea47d 100644 --- a/packages/extension/src/ui/features/actions/transaction/ApproveTransactionScreen/TransactionBanner.tsx +++ b/packages/extension/src/ui/features/actions/transaction/ApproveTransactionScreen/TransactionBanner.tsx @@ -1,63 +1,27 @@ -import React, { FC } from "react" -import styled from "styled-components" -import { DefaultTheme } from "styled-components" +import { L1 } from "@argent/ui" +import { Flex } from "@chakra-ui/react" +import { FC, ReactNode } from "react" import { ApiTransactionReviewAssessment } from "../../../../../shared/transactionReview.service" -type Variant = ApiTransactionReviewAssessment | undefined - -interface IContainer { - theme: DefaultTheme - variant: ApiTransactionReviewAssessment | undefined -} - -export const getVariantColor = ({ variant, theme }: IContainer) => { - switch (variant) { - case "warn": - return theme.red4 - } - return "#02A697" -} - -const Container = styled.div<{ variant: IContainer["variant"] }>` - background-color: ${({ theme, variant }) => - getVariantColor({ theme, variant })}; - color: ${({ theme }) => theme.text1}; - font-size: 13px; - font-weight: 600; - line-height: 18px; - padding: 12px 16px; - border-radius: 8px; - margin-bottom: 12px; - display: flex; - flex-direction: row; -` - -const IconContainer = styled.div` - margin-right: 8px; - svg { - font-size: inherit; - height: 18px; /** ensures icon is visually centered with first line of text at 18px line height */ - } -` - -export interface ITransactionBanner { - variant: Variant +interface TransactionBannerProps { + variant?: ApiTransactionReviewAssessment icon: FC - message?: string + message?: ReactNode } -export const TransactionBanner: FC = ({ +export const TransactionBanner: FC = ({ variant, icon: Icon, message, }) => { + const color = variant === "warn" ? "primary.500" : "secondary.500" return ( - - + + - - {message} - + + {message} + ) } diff --git a/packages/extension/src/ui/features/actions/transaction/ApproveTransactionScreen/index.tsx b/packages/extension/src/ui/features/actions/transaction/ApproveTransactionScreen/index.tsx deleted file mode 100644 index 1c539ffe3..000000000 --- a/packages/extension/src/ui/features/actions/transaction/ApproveTransactionScreen/index.tsx +++ /dev/null @@ -1,254 +0,0 @@ -import { WarningIcon } from "@chakra-ui/icons" -import { isArray, isEmpty } from "lodash-es" -import { FC, useMemo, useState } from "react" -import { Navigate } from "react-router-dom" -import { Call } from "starknet" - -import { - ApiTransactionReviewResponse, - ApiTransactionReviewTargettedDapp, - getDisplayWarnAndReasonForTransactionReview, -} from "../../../../../shared/transactionReview.service" -import { ApiTransactionSimulationResponse } from "../../../../../shared/transactionSimulation/types" -import { routes } from "../../../../routes" -import { normalizeAddress } from "../../../../services/addresses" -import { usePageTracking } from "../../../../services/analytics" -import { Account } from "../../../accounts/Account" -import { useAccountTransactions } from "../../../accounts/accountTransactions.state" -import { useCheckUpgradeAvailable } from "../../../accounts/upgrade.service" -import { UpgradeScreenV4 } from "../../../accounts/UpgradeScreenV4" -import { useFeeTokenBalance } from "../../../accountTokens/tokens.service" -import { useIsMainnet } from "../../../networks/useNetworks" -import { ConfirmPageProps } from "../../DeprecatedConfirmScreen" -import { CombinedFeeEstimationContainer } from "../../feeEstimation/CombinedFeeEstimation" -import { FeeEstimationContainer } from "../../feeEstimation/FeeEstimation" -import { useTransactionReview } from "../useTransactionReview" -import { - AggregatedSimData, - useAggregatedSimData, -} from "../useTransactionSimulatedData" -import { useTransactionSimulation } from "../useTransactionSimulation" -import { AccountNetworkInfo } from "./AccountNetworkInfo" -import { BalanceChangeOverview } from "./BalanceChangeOverview" -import { ConfirmScreen, ConfirmScreenProps } from "./ConfirmScreen" -import { DappHeader } from "./DappHeader" -import { SimulationLoadingBanner } from "./SimulationLoadingBanner" -import { TransactionActions } from "./TransactionActions" -import { TransactionBanner } from "./TransactionBanner" - -const VERIFIED_DAPP_ENABLED = process.env.FEATURE_VERIFIED_DAPPS === "true" - -export interface ApproveTransactionScreenProps - extends Omit { - actionHash: string - declareOrDeployType?: "declare" | "deploy" - onSubmit: (transactions: Call | Call[]) => void - selectedAccount?: Account - transactions: Call | Call[] -} - -export interface ApproveTransactionProps - extends ApproveTransactionScreenProps, - Omit { - aggregatedData: AggregatedSimData[] - isMainnet: boolean - isSimulationLoading: boolean - transactionReview?: ApiTransactionReviewResponse - transactionSimulation?: ApiTransactionSimulationResponse - selectedAccount: Account - disableConfirm: boolean - verifiedDapp?: ApiTransactionReviewTargettedDapp -} - -export const ApproveTransactionScreen: FC = ({ - actionHash, - selectedAccount, - transactions, - ...rest -}) => { - usePageTracking("signTransaction", { - networkId: selectedAccount?.networkId || "unknown", - }) - const [disableConfirm, setDisableConfirm] = useState(true) - const isMainnet = useIsMainnet() - - const { data: transactionReview } = useTransactionReview({ - account: selectedAccount, - transactions, - actionHash, - }) - const { data: transactionSimulation, isValidating: isSimulationValidating } = - useTransactionSimulation({ - account: selectedAccount, - transactions, - actionHash, - }) - - const isSimulationLoading = isSimulationValidating && !transactionSimulation - - const aggregatedData = useAggregatedSimData(transactionSimulation) - - const { feeTokenBalance } = useFeeTokenBalance(selectedAccount) - - const { needsUpgrade = false } = useCheckUpgradeAvailable(selectedAccount) - const { pendingTransactions } = useAccountTransactions(selectedAccount) - - const isUpgradeTransaction = - !Array.isArray(transactions) && transactions.entrypoint === "upgrade" - const hasUpgradeTransactionPending = pendingTransactions.some( - (t) => t.meta?.isUpgrade, - ) - const shouldShowUpgrade = Boolean( - needsUpgrade && - feeTokenBalance?.gt(0) && - !hasUpgradeTransactionPending && - !isUpgradeTransaction, - ) - - const verifiedDapp = - (VERIFIED_DAPP_ENABLED && isMainnet && transactionReview?.targetedDapp) || - undefined - - if (!selectedAccount) { - return - } - - if (shouldShowUpgrade) { - return - } - - return ( - - ) : ( - - ) - } - {...rest} - /> - ) -} - -export const ApproveTransaction: FC = ({ - actionHash, - aggregatedData, - declareOrDeployType, - disableConfirm, - isMainnet, - isSimulationLoading, - onSubmit, - selectedAccount, - transactionReview, - transactions, - transactionSimulation, - verifiedDapp, - ...rest -}) => { - const transactionsArray: Call[] = useMemo( - () => (isArray(transactions) ? transactions : [transactions]), - [transactions], - ) - - const txnHasTransfers = useMemo( - () => !isEmpty(transactionSimulation?.transfers), - [transactionSimulation], - ) - - const txnHasApprovals = useMemo( - () => !isEmpty(transactionSimulation?.approvals), - [transactionSimulation], - ) - - const isUdcAction = useMemo( - () => Boolean(declareOrDeployType), - [declareOrDeployType], - ) - - const { warn, reason } = - getDisplayWarnAndReasonForTransactionReview(transactionReview) - - // Show balance change if there is a transaction simulation and there are approvals or transfers - const hasBalanceChange = - transactionSimulation && (txnHasTransfers || txnHasApprovals) - - // Show actions if there is no balance change or if there is a balance change and the user has expanded the details - const showTransactionActions = !isUdcAction - - return ( - { - onSubmit(transactions) - }} - showHeader={true} - {...rest} - > - {/** Use Transaction Review to get DappHeader */} - - - {warn && ( - - )} - - {hasBalanceChange ? ( - - ) : ( - isSimulationLoading && - )} - {showTransactionActions && ( - - )} - - - - ) -} diff --git a/packages/extension/src/ui/features/actions/transaction/DefaultTransactionDetails.tsx b/packages/extension/src/ui/features/actions/transaction/DefaultTransactionDetails.tsx deleted file mode 100644 index 5a4dc3b42..000000000 --- a/packages/extension/src/ui/features/actions/transaction/DefaultTransactionDetails.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import { Collapse } from "@mui/material" -import { FC, useCallback, useState } from "react" -import styled from "styled-components" - -import { entryPointToHumanReadable } from "../../../../shared/transactions" -import { CopyTooltip } from "../../../components/CopyTooltip" -import { DisclosureIcon } from "../../../components/DisclosureIcon" -import { - Field, - FieldGroup, - FieldKey, - FieldValue, - LeftPaddedField, -} from "../../../components/Fields" -import { ContentCopyIcon } from "../../../components/Icons/MuiIcons" -import { ContractField } from "./fields/ContractField" -import { MaybeDappContractField } from "./fields/DappContractField" -import { TransactionDetailsProps } from "./TransactionDetails" - -const TransactionDetailsField = styled(Field)` - flex-direction: column; - align-items: flex-start; - gap: 4px; -` - -const TransactionDetailKey = styled(FieldKey)` - display: flex; - align-items: center; - gap: 7px; -` - -const TransactionJson = styled.pre` - font-weight: normal; - font-size: 12px; - line-height: 12px; - color: ${({ theme }) => theme.text2}; -` - -export const DefaultTransactionDetails: FC = ({ - transaction, -}) => { - const [expanded, setExpanded] = useState(false) - const toggleExpanded = useCallback(() => { - setExpanded((expanded) => !expanded) - }, []) - const displayTransactionDetails = JSON.stringify( - transaction.calldata, - null, - 2, - ) - return ( - - - - - Action - - {entryPointToHumanReadable(transaction.entrypoint)} - - - - View details - - - - - - - -
Transaction details
- - - -
- - {displayTransactionDetails} - -
-
-
- ) -} diff --git a/packages/extension/src/ui/features/actions/transaction/ERC20ApproveTransactionDetails.tsx b/packages/extension/src/ui/features/actions/transaction/ERC20ApproveTransactionDetails.tsx deleted file mode 100644 index d67e29767..000000000 --- a/packages/extension/src/ui/features/actions/transaction/ERC20ApproveTransactionDetails.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { FC } from "react" - -import { - Erc20ApproveCall, - parseErc20ApproveCall, -} from "../../../../shared/call/erc20ApproveCall" -import { Token } from "../../../../shared/token/type" -import { - Field, - FieldGroup, - FieldKey, - FieldValue, -} from "../../../components/Fields" -import { useDisplayTokenAmountAndCurrencyValue } from "../../accountTokens/useDisplayTokenAmountAndCurrencyValue" -import { TokenField } from "./fields/TokenField" - -/** Renders an ERC20 approve transaction */ - -export interface Erc20ApproveCallTransactionItemProps { - transaction: Erc20ApproveCall - tokensByNetwork: Token[] - networkId: string -} - -export const ERC20ApproveTransactionDetails: FC< - Erc20ApproveCallTransactionItemProps -> = ({ transaction, tokensByNetwork }) => { - const { contractAddress, amount } = parseErc20ApproveCall(transaction) - - const { displayValue } = useDisplayTokenAmountAndCurrencyValue({ - amount, - tokenAddress: contractAddress, - }) - - return ( - - - {!!displayValue && ( - - Value - {displayValue} - - )} - - ) -} diff --git a/packages/extension/src/ui/features/actions/transaction/ERC20TransferTransactionDetails.tsx b/packages/extension/src/ui/features/actions/transaction/ERC20TransferTransactionDetails.tsx deleted file mode 100644 index 1e18cdcb0..000000000 --- a/packages/extension/src/ui/features/actions/transaction/ERC20TransferTransactionDetails.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { FC } from "react" - -import { - Erc20TransferCall, - parseErc20TransferCall, -} from "../../../../shared/call/erc20TransferCall" -import { Token } from "../../../../shared/token/type" -import { - Field, - FieldGroup, - FieldKey, - FieldValue, -} from "../../../components/Fields" -import { useDisplayTokenAmountAndCurrencyValue } from "../../accountTokens/useDisplayTokenAmountAndCurrencyValue" -import { AccountAddressField } from "./fields/AccountAddressField" -import { TokenField } from "./fields/TokenField" - -/** Renders an ERC20 transfer transaction */ - -export interface Erc20TransferCallTransactionItemProps { - transaction: Erc20TransferCall - tokensByNetwork: Token[] - networkId: string -} - -export const ERC20TransferTransactionDetails: FC< - Erc20TransferCallTransactionItemProps -> = ({ transaction, tokensByNetwork, networkId }) => { - const { contractAddress, recipientAddress, amount } = - parseErc20TransferCall(transaction) - - const { displayValue } = useDisplayTokenAmountAndCurrencyValue({ - amount, - tokenAddress: contractAddress, - }) - - return ( - - - {!!displayValue && ( - - Value - {displayValue} - - )} - - - ) -} diff --git a/packages/extension/src/ui/features/actions/transaction/TransactionDetails.tsx b/packages/extension/src/ui/features/actions/transaction/TransactionDetails.tsx deleted file mode 100644 index c7356e92e..000000000 --- a/packages/extension/src/ui/features/actions/transaction/TransactionDetails.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { FC } from "react" -import { Call } from "starknet" - -import { isErc20ApproveCall } from "../../../../shared/call/erc20ApproveCall" -import { isErc20TransferCall } from "../../../../shared/call/erc20TransferCall" -import { Token } from "../../../../shared/token/type" -import { DefaultTransactionDetails } from "./DefaultTransactionDetails" -import { ERC20ApproveTransactionDetails } from "./ERC20ApproveTransactionDetails" -import { ERC20TransferTransactionDetails } from "./ERC20TransferTransactionDetails" - -export interface TransactionDetailsProps { - transaction: Call - tokensByNetwork: Token[] - networkId: string -} - -/** Renders a single transaction */ - -export const TransactionDetails: FC = ({ - transaction, - tokensByNetwork, - networkId, -}) => { - if (isErc20TransferCall(transaction)) { - return ( - - ) - } else if (isErc20ApproveCall(transaction)) { - return ( - - ) - } - return ( - - ) -} diff --git a/packages/extension/src/ui/features/actions/transaction/TransactionsListSwap.tsx b/packages/extension/src/ui/features/actions/transaction/TransactionsListSwap.tsx deleted file mode 100644 index a0ac738b3..000000000 --- a/packages/extension/src/ui/features/actions/transaction/TransactionsListSwap.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import { FC } from "react" -import styled from "styled-components" - -import { prettifyCurrencyValue } from "../../../../shared/token/price" -import { Token } from "../../../../shared/token/type" -import { - ApiTransactionReviewResponse, - getTransactionReviewSwap, -} from "../../../../shared/transactionReview.service" -import { entryPointToHumanReadable } from "../../../../shared/transactions" -import { - Field, - FieldGroup, - FieldKey, - FieldValue, - LeftPaddedField, -} from "../../../components/Fields" -import { DappIcon } from "../connectDapp/DappIcon" -import { ContractField } from "./fields/ContractField" -import { TokenField } from "./fields/TokenField" - -const DappIconContainer = styled.div` - width: 24px; - height: 24px; -` - -export interface ITransactionsListSwap { - transactionReview?: ApiTransactionReviewResponse - tokensByNetwork?: Token[] -} - -export const TransactionsListSwap: FC = ({ - transactionReview, - tokensByNetwork = [], -}) => { - const swap = getTransactionReviewSwap(transactionReview) - if (!swap) { - return null - } - /** API only checks Jediswap at present */ - const isJediswap = - transactionReview?.targetedDapp.name.toLowerCase() === "jediswap" - const { activity, assessmentDetails } = swap - - return ( - <> - - - - Value - {prettifyCurrencyValue(activity?.src?.usd)} - - - Slippage - - {entryPointToHumanReadable(activity?.src?.slippage || "–")} - - - - - - - Value - {prettifyCurrencyValue(activity?.dst?.usd)} - - - Slippage - - {entryPointToHumanReadable(activity?.dst?.slippage || "–")} - - - - - - - dApp - - {isJediswap && ( - - - - )} - - {transactionReview?.targetedDapp.name || "Unknown"} - - - - - - ) -} diff --git a/packages/extension/src/ui/features/actions/transaction/fields/AccountAddressField.tsx b/packages/extension/src/ui/features/actions/transaction/fields/AccountAddressField.tsx index 1e930d7ce..f953fc1e8 100644 --- a/packages/extension/src/ui/features/actions/transaction/fields/AccountAddressField.tsx +++ b/packages/extension/src/ui/features/actions/transaction/fields/AccountAddressField.tsx @@ -3,14 +3,14 @@ import { FC, ReactNode } from "react" import { Field, FieldKey, LeftPaddedField } from "../../../../components/Fields" import { PrettyAccountAddress } from "../../../accounts/PrettyAccountAddress" -interface IAccountAddressField { +interface AccountAddressFieldProps { title: string accountAddress: string networkId: string fallbackValue?: (accountAddress: string) => ReactNode } -export const AccountAddressField: FC = ({ +export const AccountAddressField: FC = ({ title, accountAddress, networkId, diff --git a/packages/extension/src/ui/features/actions/transaction/fields/ContractField.tsx b/packages/extension/src/ui/features/actions/transaction/fields/ContractField.tsx index b2e96a3de..4f82ca51c 100644 --- a/packages/extension/src/ui/features/actions/transaction/fields/ContractField.tsx +++ b/packages/extension/src/ui/features/actions/transaction/fields/ContractField.tsx @@ -5,11 +5,11 @@ import { Field, FieldKey, FieldValue } from "../../../../components/Fields" import { ContentCopyIcon } from "../../../../components/Icons/MuiIcons" import { formatTruncatedAddress } from "../../../../services/addresses" -export interface IContractField { +interface ContractFieldProps { contractAddress?: string } -export const ContractField: FC = ({ contractAddress }) => { +export const ContractField: FC = ({ contractAddress }) => { if (!contractAddress) { return null } diff --git a/packages/extension/src/ui/features/actions/transaction/fields/DappContractField.tsx b/packages/extension/src/ui/features/actions/transaction/fields/DappContractField.tsx index 87a89c393..c29e204b3 100644 --- a/packages/extension/src/ui/features/actions/transaction/fields/DappContractField.tsx +++ b/packages/extension/src/ui/features/actions/transaction/fields/DappContractField.tsx @@ -1,10 +1,7 @@ +import { chakra } from "@chakra-ui/react" import { FC } from "react" -import styled from "styled-components" -import { - KnownDapp, - getKnownDappForContractAddress, -} from "../../../../../shared/knownDapps" +import { KnownDapp } from "../../../../../shared/knownDapps" import { Field, FieldKey, @@ -14,31 +11,16 @@ import { import { DappIcon } from "../../connectDapp/DappIcon" import { useDappDisplayAttributes } from "../../connectDapp/useDappDisplayAttributes" -const DappFieldValue = styled(FieldValue)` - margin-left: 8px; -` - -const DappIconContainer = styled.div` - width: 24px; - height: 24px; - display: flex; - flex-shrink: 0; -` +const DappFieldValue = chakra(FieldValue, { + baseStyle: { marginLeft: 2 }, +}) -export const MaybeDappContractField: FC<{ contractAddress: string }> = ({ - contractAddress, -}) => { - const knownContract = getKnownDappForContractAddress(contractAddress) - if (!knownContract) { - return null - } - return -} - -export const DappContractField: FC<{ +interface DappContractFieldProps { knownContract: Omit useDappDisplayAttributesImpl?: typeof useDappDisplayAttributes -}> = ({ +} + +export const DappContractField: FC = ({ knownContract, useDappDisplayAttributesImpl = useDappDisplayAttributes, }) => { @@ -48,12 +30,11 @@ export const DappContractField: FC<{ Dapp - - - + {dappDisplayAttributes?.title || host} diff --git a/packages/extension/src/ui/features/actions/transaction/fields/FeeField.tsx b/packages/extension/src/ui/features/actions/transaction/fields/FeeField.tsx index 0296697c7..d7e8d6efb 100644 --- a/packages/extension/src/ui/features/actions/transaction/fields/FeeField.tsx +++ b/packages/extension/src/ui/features/actions/transaction/fields/FeeField.tsx @@ -1,29 +1,18 @@ -import { TextWithAmount } from "@argent/ui" +import { L1, TextWithAmount } from "@argent/ui" +import { Box } from "@chakra-ui/react" import { FC } from "react" -import styled from "styled-components" import { Field, FieldKey, LeftPaddedField } from "../../../../components/Fields" import { useNetworkFeeToken } from "../../../accountTokens/tokens.state" import { useDisplayTokenAmountAndCurrencyValue } from "../../../accountTokens/useDisplayTokenAmountAndCurrencyValue" -const FeeAmount = styled.div` - text-align: right; -` -const FeeValue = styled.div` - text-align: right; - color: ${({ theme }) => theme.text2}; - font-size: 12px; - line-height: 14px; - margin-top: 2px; -` - -export interface IFeeField { +interface FeeFieldProps { title?: string fee: string networkId: string } -export const FeeField: FC = ({ +export const FeeField: FC = ({ title = "Network fee", fee, networkId, @@ -40,9 +29,9 @@ export const FeeField: FC = ({ {title} - {displayAmount} + {displayAmount} - {displayValue && {displayValue}} + {displayValue && {displayValue}} ) diff --git a/packages/extension/src/ui/features/actions/transaction/fields/MaybeDappContractField.tsx b/packages/extension/src/ui/features/actions/transaction/fields/MaybeDappContractField.tsx new file mode 100644 index 000000000..b19fed903 --- /dev/null +++ b/packages/extension/src/ui/features/actions/transaction/fields/MaybeDappContractField.tsx @@ -0,0 +1,18 @@ +import { FC } from "react" + +import { getKnownDappForContractAddress } from "../../../../../shared/knownDapps" +import { DappContractField } from "./DappContractField" + +interface MaybeDappContractFieldProps { + contractAddress: string +} + +export const MaybeDappContractField: FC = ({ + contractAddress, +}) => { + const knownContract = getKnownDappForContractAddress(contractAddress) + if (!knownContract) { + return null + } + return +} diff --git a/packages/extension/src/ui/features/actions/transaction/fields/ParameterField.tsx b/packages/extension/src/ui/features/actions/transaction/fields/ParameterField.tsx index 369a89ae1..83dfccf49 100644 --- a/packages/extension/src/ui/features/actions/transaction/fields/ParameterField.tsx +++ b/packages/extension/src/ui/features/actions/transaction/fields/ParameterField.tsx @@ -1,5 +1,5 @@ +import { chakra } from "@chakra-ui/react" import { FC } from "react" -import styled from "styled-components" import { IExplorerTransactionParameters } from "../../../../../shared/explorer/type" import { entryPointToHumanReadable } from "../../../../../shared/transactions" @@ -11,25 +11,29 @@ import { } from "../../../../services/addresses" import { AccountAddressField } from "./AccountAddressField" -interface IParameterField { +interface ParameterFieldProps { parameter: IExplorerTransactionParameters networkId?: string } -const StyledCopyIconButton = styled(CopyIconButton)` - position: relative; - left: 12px; -` +const StyledCopyIconButton = chakra(CopyIconButton, { + baseStyle: { + position: "relative", + left: 3, + }, +}) -const ParameterFieldValue = styled(FieldValue)` - display: block; - white-space: nowrap; - text-overflow: ellipsis; - overflow: hidden; - margin-left: 8px; -` +const ParameterFieldValue = chakra(FieldValue, { + baseStyle: { + display: "block", + whiteSpace: "nowrap", + textOverflow: "ellipsis", + overflow: "hidden", + marginLeft: 2, + }, +}) -export const ParameterField: FC = ({ +export const ParameterField: FC = ({ parameter, networkId, }) => { diff --git a/packages/extension/src/ui/features/actions/transaction/fields/TokenField.tsx b/packages/extension/src/ui/features/actions/transaction/fields/TokenField.tsx index 19616d517..c9616e614 100644 --- a/packages/extension/src/ui/features/actions/transaction/fields/TokenField.tsx +++ b/packages/extension/src/ui/features/actions/transaction/fields/TokenField.tsx @@ -13,14 +13,14 @@ import { import { isEqualAddress } from "../../../../services/addresses" import { TokenIcon } from "../../../accountTokens/TokenIcon" -export interface ITokenField { +interface TokenFieldProps { label: string contractAddress?: string amount?: BigNumberish tokensByNetwork: Token[] } -export const TokenField: FC = ({ +export const TokenField: FC = ({ label, contractAddress, amount, diff --git a/packages/extension/src/ui/features/actions/transaction/types.ts b/packages/extension/src/ui/features/actions/transaction/types.ts new file mode 100644 index 000000000..1542bbd3f --- /dev/null +++ b/packages/extension/src/ui/features/actions/transaction/types.ts @@ -0,0 +1,42 @@ +import { Call } from "starknet" + +import { ArgentAccountType } from "../../../../shared/wallet.model" + +export enum ApproveScreenType { + TRANSACTION, + DECLARE, + DEPLOY, + ACCOUNT_DEPLOY, + MULTISIG_DEPLOY, + MULTISIG_ADD_SIGNERS, + MULTISIG_UPDATE_THRESHOLD, + MULTISIG_REMOVE_SIGNER, + ADD_ARGENT_SHIELD, + REMOVE_ARGENT_SHIELD, +} + +export type TransactionActionsType = + | { + type: "INVOKE_FUNCTION" + payload: Call[] + } + | { + type: "DEPLOY_ACCOUNT" + payload: { + accountAddress: string + classHash?: string + type: ArgentAccountType + } + } + | { + type: "ADD_ARGENT_SHIELD" + payload: { + accountAddress: string + } + } + | { + type: "REMOVE_ARGENT_SHIELD" + payload: { + accountAddress: string + } + } diff --git a/packages/extension/src/ui/features/actions/transaction/useTransactionSimulatedData.ts b/packages/extension/src/ui/features/actions/transaction/useTransactionSimulatedData.ts index ff2e421cf..3e2045625 100644 --- a/packages/extension/src/ui/features/actions/transaction/useTransactionSimulatedData.ts +++ b/packages/extension/src/ui/features/actions/transaction/useTransactionSimulatedData.ts @@ -22,7 +22,7 @@ import { useAspectContractAddresses } from "./../../accountNfts/aspect.service" import { Account } from "../../accounts/Account" import { useSelectedAccount } from "../../accounts/accounts.state" import { useTokensRecord } from "../../accountTokens/tokens.state" -import { useCurrentNetwork } from "../../networks/useNetworks" +import { useCurrentNetwork } from "../../networks/hooks/useCurrentNetwork" interface CommonSimulationData { token: Token diff --git a/packages/extension/src/ui/features/actions/utils.ts b/packages/extension/src/ui/features/actions/utils.ts new file mode 100644 index 000000000..42844bbf2 --- /dev/null +++ b/packages/extension/src/ui/features/actions/utils.ts @@ -0,0 +1,44 @@ +import { + QueueItem, + TransactionActionPayload, +} from "../../../shared/actionQueue/types" +import { MultisigPendingTransaction } from "../../../shared/multisig/pendingTransactionsStore" +import { ApproveScreenType } from "./transaction/types" + +export const getApproveScreenTypeFromAction = ( + action: QueueItem & { + type: "TRANSACTION" + payload: TransactionActionPayload + }, +) => { + switch (action.payload.meta?.type) { + case "MULTISIG_ADD_SIGNERS": + return ApproveScreenType.MULTISIG_ADD_SIGNERS + case "MULTISIG_UPDATE_THRESHOLD": + return ApproveScreenType.MULTISIG_UPDATE_THRESHOLD + case "MULTISIG_REMOVE_SIGNER": + return ApproveScreenType.MULTISIG_REMOVE_SIGNER + case "ADD_ARGENT_SHIELD": + return ApproveScreenType.ADD_ARGENT_SHIELD + case "REMOVE_ARGENT_SHIELD": + return ApproveScreenType.REMOVE_ARGENT_SHIELD + default: + return ApproveScreenType.TRANSACTION + } +} + +export const getApproveScreenTypeFromPendingTransaction = ( + pendingTransaction: MultisigPendingTransaction, +) => { + switch (pendingTransaction.type) { + case "MULTISIG_ADD_SIGNERS": + return ApproveScreenType.MULTISIG_ADD_SIGNERS + case "MULTISIG_UPDATE_THRESHOLD": + return ApproveScreenType.MULTISIG_UPDATE_THRESHOLD + case "MULTISIG_REMOVE_SIGNER": + return ApproveScreenType.MULTISIG_REMOVE_SIGNER + + default: + return ApproveScreenType.TRANSACTION + } +} diff --git a/packages/extension/src/ui/features/funding/FundingBridgeScreen.tsx b/packages/extension/src/ui/features/funding/FundingBridgeScreen.tsx index 536dcf557..8942f9591 100644 --- a/packages/extension/src/ui/features/funding/FundingBridgeScreen.tsx +++ b/packages/extension/src/ui/features/funding/FundingBridgeScreen.tsx @@ -8,13 +8,14 @@ import { PageWrapper } from "../../components/Page" import { A } from "../../components/TrackingLink" import { routes } from "../../routes" import { trackAddFundsService } from "../../services/analytics" -import { useSelectedAccount } from "../accounts/accounts.state" +import { selectedAccountView } from "../../views/account" +import { useView } from "../../views/implementation/react" import EthereumSvg from "./ethereum.svg" import { Title } from "./FundingScreen" import OrbiterSvg from "./orbiter.svg" export const FundingBridgeScreen: FC = () => { - const account = useSelectedAccount() + const account = useView(selectedAccountView) const navigate = useNavigate() if (!account) { diff --git a/packages/extension/src/ui/features/funding/FundingProviderScreen.tsx b/packages/extension/src/ui/features/funding/FundingProviderScreen.tsx index ca6fa6dee..b73915ffc 100644 --- a/packages/extension/src/ui/features/funding/FundingProviderScreen.tsx +++ b/packages/extension/src/ui/features/funding/FundingProviderScreen.tsx @@ -13,8 +13,9 @@ import { A } from "../../components/TrackingLink" import { routes } from "../../routes" import { normalizeAddress } from "../../services/addresses" import { trackAddFundsService } from "../../services/analytics" -import { useSelectedAccount } from "../accounts/accounts.state" -import { useIsMainnet } from "../networks/useNetworks" +import { selectedAccountView } from "../../views/account" +import { useView } from "../../views/implementation/react" +import { useIsMainnet } from "../networks/hooks/useIsMainnet" import BanxaSvg from "./banxa.svg" import RampSvg from "./ramp.svg" @@ -53,7 +54,7 @@ const RAMP_ENABLED = export const FundingProviderScreen: FC = () => { const navigate = useNavigate() - const account = useSelectedAccount() + const account = useView(selectedAccountView) const isMainnet = useIsMainnet() const allowFiatPurchase = account && isMainnet diff --git a/packages/extension/src/ui/features/funding/FundingQrCodeScreen.tsx b/packages/extension/src/ui/features/funding/FundingQrCodeScreen.tsx index dab763aaf..367663a2d 100644 --- a/packages/extension/src/ui/features/funding/FundingQrCodeScreen.tsx +++ b/packages/extension/src/ui/features/funding/FundingQrCodeScreen.tsx @@ -9,11 +9,8 @@ import { PageWrapper } from "../../components/Page" import { routes } from "../../routes" import { formatFullAddress, normalizeAddress } from "../../services/addresses" import { usePageTracking } from "../../services/analytics" -import { - getAccountName, - useAccountMetadata, -} from "../accounts/accountMetadata.state" -import { useSelectedAccount } from "../accounts/accounts.state" +import { selectedAccountView } from "../../views/account" +import { useView } from "../../views/implementation/react" import { QrCode } from "./QrCode" const Container = styled.div` @@ -29,11 +26,10 @@ const StyledCopyIconButton = styled(CopyIconButton)` export const FundingQrCodeScreen: FC = () => { const navigate = useNavigate() const addressRef = useRef(null) - const account = useSelectedAccount() + const account = useView(selectedAccountView) usePageTracking("addFundsFromOtherAccount", { networkId: account?.networkId || "unknown", }) - const { accountNames } = useAccountMetadata() const copyAccountAddress = account ? normalizeAddress(account.address) : "" /** Intercept 'copy' event and replace fragmented address with plain text address */ @@ -82,7 +78,7 @@ export const FundingQrCodeScreen: FC = () => { {account && ( - {getAccountName(account, accountNames)} + {account.name} { - const account = useSelectedAccount() + const account = useView(selectedAccountView) const navigate = useNavigate() const toast = useToast() usePageTracking("addFunds", { diff --git a/packages/extension/src/ui/features/multisig/AddOwnerForm.tsx b/packages/extension/src/ui/features/multisig/AddOwnerForm.tsx new file mode 100644 index 000000000..2e6d28056 --- /dev/null +++ b/packages/extension/src/ui/features/multisig/AddOwnerForm.tsx @@ -0,0 +1,96 @@ +import { FieldError, P3, RoundButton, icons } from "@argent/ui" +import { + Box, + Button, + Center, + Input, + InputGroup, + InputRightElement, +} from "@chakra-ui/react" +import { useCallback, useEffect } from "react" +import { useFieldArray, useFormContext } from "react-hook-form" + +import { FieldValuesCreateMultisigForm } from "./hooks/useCreateMultisigForm" + +const { CloseIcon, AddIcon } = icons + +export const AddOwnersForm = ({ + nextOwnerIndex, +}: { + nextOwnerIndex: number +}) => { + const { + control, + formState: { errors }, + register, + } = useFormContext() + + const { fields, append, remove } = useFieldArray({ + name: "signerKeys", + control, + }) + + const addOwner = useCallback(() => { + append({ key: "" }) + }, [append]) + + // This allows to add an owner when the form is first rendered (will render two in dev mode) + useEffect(() => { + if (fields.length === 0) { + addOwner() + } + }, [addOwner, fields.length]) + + return ( + <> + {fields.map((field, index) => { + return ( + + Owner {nextOwnerIndex + index} + + + + remove(index)} + height="5" + size="xs" + mr="2" + my="0" + mt="1em" + pb="0" + variant="link" + > + + + + + {errors.signerKeys && ( + + {errors.signerKeys?.[index]?.key?.message} + + )} + + ) + })} + {errors.signerKeys?.message && ( + {errors.signerKeys?.message} + )} +
+ +
+ + ) +} diff --git a/packages/extension/src/ui/features/multisig/CreateMultisigScreen/CreateMultisigStartScreen.tsx b/packages/extension/src/ui/features/multisig/CreateMultisigScreen/CreateMultisigStartScreen.tsx new file mode 100644 index 000000000..07957f7eb --- /dev/null +++ b/packages/extension/src/ui/features/multisig/CreateMultisigScreen/CreateMultisigStartScreen.tsx @@ -0,0 +1,53 @@ +import { useState } from "react" +import { FormProvider } from "react-hook-form" +import { useParams } from "react-router-dom" + +import { useNextSignerKey } from "../../accounts/usePublicKey" +import { useCreateMultisigForm } from "../hooks/useCreateMultisigForm" +import { MultisigFirstStep } from "./MultisigFirstStep" +import { MultisigSecondStep } from "./MultisigSecondStep" +import { MultisigThirdStep } from "./MultisigThirdStep" + +const FIRST_STEP = 0 +const SECOND_STEP = 1 +const THIRD_STEP = 2 + +export const CreateMultisigStartScreen = () => { + const { networkId } = useParams() + if (!networkId) { + return <> + } + return +} + +const MultisigCreationForm = ({ networkId }: { networkId: string }) => { + const [currentStep, setStep] = useState(FIRST_STEP) + const creatorSignerKey = useNextSignerKey(networkId) + + const methods = useCreateMultisigForm(creatorSignerKey) + const goBack = () => setStep((step) => step - 1) + const goNext = () => setStep((step) => step + 1) + + return ( + + {currentStep === FIRST_STEP && ( + + )} + {currentStep === SECOND_STEP && ( + + )} + {currentStep === THIRD_STEP && ( + + )} + + ) +} diff --git a/packages/extension/src/ui/features/multisig/CreateMultisigScreen/MultisigFirstStep.test.tsx b/packages/extension/src/ui/features/multisig/CreateMultisigScreen/MultisigFirstStep.test.tsx new file mode 100644 index 000000000..c1472089f --- /dev/null +++ b/packages/extension/src/ui/features/multisig/CreateMultisigScreen/MultisigFirstStep.test.tsx @@ -0,0 +1,64 @@ +import { fireEvent, render, screen } from "@testing-library/react" + +import { MultisigFirstStep } from "./MultisigFirstStep" + +test("renders the MultisigFirstStep component", () => { + vi.mock("react-hook-form", async () => { + const actual = await vi.importActual("react-hook-form") + return { + ...(actual as object), + useFormContext: vi.fn(() => { + return { + control: undefined, + formState: { errors: [] }, + register: vi.fn(() => undefined), + trigger: vi.fn(() => Promise.resolve(true)), + } + }), + useFieldArray: vi.fn(() => { + return { + append: () => undefined, + remove: () => undefined, + fields: [{ key: "" }], + } + }), + } + }) + + const goNext = () => undefined + describe("it should allow to add owners", () => { + render( + , + ) + // Assert that the title and subtitle are rendered + expect(screen.getByText("Add owners")).toBeInTheDocument() + expect( + screen.getByText(/Ask your co-owners to go to “Join existing multisig”/i), + ).toBeInTheDocument() + + // Assert that the inputs are rendered and have the correct labels + expect(screen.getByText("Owner 1 (Me)")).toBeInTheDocument() + + // Click the "Add another owner" button + const addOwnerButton = screen.getByText("Add another owner") + fireEvent.click(addOwnerButton) + + // Fill out the owner 2 input with a valid value + const owner2Input = screen.getByPlaceholderText("Signer key...") + expect(owner2Input).not.toBeNull() + }) + describe("it should match the snapshots", () => { + const { container } = render( + , + ) + expect(container.firstChild).toMatchSnapshot() + }) +}) diff --git a/packages/extension/src/ui/features/multisig/CreateMultisigScreen/MultisigFirstStep.tsx b/packages/extension/src/ui/features/multisig/CreateMultisigScreen/MultisigFirstStep.tsx new file mode 100644 index 000000000..6a54aefb1 --- /dev/null +++ b/packages/extension/src/ui/features/multisig/CreateMultisigScreen/MultisigFirstStep.tsx @@ -0,0 +1,57 @@ +import { P3 } from "@argent/ui" +import { Box, Button, Divider, Input } from "@chakra-ui/react" +import { useFormContext } from "react-hook-form" + +import { useNextSignerKey } from "../../accounts/usePublicKey" +import { AddOwnersForm } from "../AddOwnerForm" +import { FieldValuesCreateMultisigForm } from "../hooks/useCreateMultisigForm" +import { ScreenLayout } from "./ScreenLayout" + +export const MultisigFirstStep = ({ + index, + goNext, + networkId, +}: { + networkId: string + index: number + goNext: () => void +}) => { + const { register, trigger } = useFormContext() + const creatorSignerKey = useNextSignerKey(networkId) + const handleNavigationToConfirmationScreen = async () => { + const isValid = await trigger("signerKeys") + if (isValid) { + goNext() + } + } + + return ( + + A signer key is NOT an account address + + + Owner 1 (Me) + + + + + + ) +} diff --git a/packages/extension/src/ui/features/multisig/CreateMultisigScreen/MultisigSecondStep.tsx b/packages/extension/src/ui/features/multisig/CreateMultisigScreen/MultisigSecondStep.tsx new file mode 100644 index 000000000..5003a6add --- /dev/null +++ b/packages/extension/src/ui/features/multisig/CreateMultisigScreen/MultisigSecondStep.tsx @@ -0,0 +1,90 @@ +import { FieldError } from "@argent/ui" +import { Box, Button } from "@chakra-ui/react" +import { useFormContext } from "react-hook-form" + +import { accountService } from "../../../../shared/account/service" +import { isEmptyValue } from "../../../../shared/utils/object" +import { useAction } from "../../../hooks/useAction" +import { useSelectedAccount } from "../../accounts/accounts.state" +import { useNextPublicKey, useNextSignerKey } from "../../accounts/usePublicKey" +import { FieldValuesCreateMultisigForm } from "../hooks/useCreateMultisigForm" +import { SetConfirmationsInput } from "../SetConfirmationsInput" +import { ScreenLayout } from "./ScreenLayout" + +export const MultisigSecondStep = ({ + index, + goBack, + goNext, + networkId, +}: { + networkId: string + index: number + goBack: () => void + goNext: () => void +}) => { + const creatorPubKey = useNextPublicKey(networkId) + const creatorSignerKey = useNextSignerKey(networkId) + const { action: createAccount, error: isError } = useAction( + accountService.create, + ) + const { + formState: { errors }, + getValues, + trigger, + } = useFormContext() + + const selectedAccount = useSelectedAccount() + + const handleCreateMultisig = async () => { + await trigger() + if ( + isEmptyValue(errors) && + creatorPubKey && + creatorSignerKey && + selectedAccount + ) { + const signers = [creatorSignerKey].concat( + getValues("signerKeys").map((i) => i.key), + ) + + const threshold = getValues("confirmations") + + const result = await createAccount( + "multisig", + selectedAccount.networkId, + { + creator: creatorPubKey, + signers, + threshold, + publicKey: creatorPubKey, + }, + ) + if (result) { + goNext() + } + } + } + + return ( + + + + {isError && ( + + Something went wrong creating the multisig + + )} + + ) +} diff --git a/packages/extension/src/ui/features/multisig/CreateMultisigScreen/MultisigThirdStep.tsx b/packages/extension/src/ui/features/multisig/CreateMultisigScreen/MultisigThirdStep.tsx new file mode 100644 index 000000000..9052b030a --- /dev/null +++ b/packages/extension/src/ui/features/multisig/CreateMultisigScreen/MultisigThirdStep.tsx @@ -0,0 +1,36 @@ +import { H1 } from "@argent/ui" +import { icons } from "@argent/ui" +import { Button } from "@chakra-ui/react" + +import { ScreenLayout } from "./ScreenLayout" + +const { TickCircleIcon } = icons +export const MultisigThirdStep = ({ + index, + goBack, +}: { + index: number + goBack: () => void +}) => { + const handleFinish = () => { + window.close() + } + return ( + + Multisig created{" "} + + + } + goBack={goBack} + back={true} + > + + + ) +} diff --git a/packages/extension/src/ui/features/multisig/CreateMultisigScreen/ScreenLayout.tsx b/packages/extension/src/ui/features/multisig/CreateMultisigScreen/ScreenLayout.tsx new file mode 100644 index 000000000..04fe89367 --- /dev/null +++ b/packages/extension/src/ui/features/multisig/CreateMultisigScreen/ScreenLayout.tsx @@ -0,0 +1,114 @@ +import { H1, P2, logos } from "@argent/ui" +import { Box, Button } from "@chakra-ui/react" +import { isNumber } from "lodash-es" +import { FC, PropsWithChildren, ReactNode } from "react" + +import { ContentWrapper } from "../../../components/FullScreenPage" +import { ArrowBackIcon } from "../../../components/Icons/MuiIcons" +import { StepIndicator } from "../../../components/StepIndicator" + +const { ArgentXLogo } = logos + +export interface CreateMultisigScreen extends PropsWithChildren { + back?: boolean + title?: string | ReactNode + subtitle?: string + length?: number + currentIndex?: number + goBack?: () => void +} + +const Panel = (props: React.HTMLAttributes) => ( + +) + +const PageWrapper = (props: React.HTMLAttributes) => { + return ( + + {props.children} + + + + + + + ) +} + +export const ScreenLayout: FC = ({ + back, + title, + subtitle, + children, + length = 3, + currentIndex, + goBack, +}) => { + const indicator = isNumber(length) && isNumber(currentIndex) + + return ( + + {back && goBack && ( + + )} + + + {indicator && ( + + )} + + {title && typeof title === "string" ? ( +

{title}

+ ) : ( + <>{title} + )} + {subtitle && ( + + {subtitle} + + )} +
+ {children} +
+
+
+ ) +} diff --git a/packages/extension/src/ui/features/multisig/JoinMultisigScreen.tsx b/packages/extension/src/ui/features/multisig/JoinMultisigScreen.tsx new file mode 100644 index 000000000..f629c415b --- /dev/null +++ b/packages/extension/src/ui/features/multisig/JoinMultisigScreen.tsx @@ -0,0 +1,79 @@ +import { B3, Button, H5, NavigationContainer, P3, icons } from "@argent/ui" +import { Box, Flex, Spinner, useClipboard } from "@chakra-ui/react" +import { FC, useEffect } from "react" +import { useNavigate, useParams } from "react-router-dom" + +import { useEncodedPublicKey } from "../accounts/usePublicKey" +import { IconWrapper } from "../actions/transaction/ApproveTransactionScreen/DappHeader/TransactionIcon/IconWrapper" +import { recover } from "../recovery/recovery.service" + +const { CopyIcon, ShareIcon } = icons + +export const JoinMultisigScreen: FC = () => { + const navigate = useNavigate() + + const { publicKey } = useParams() + + const signerKey = useEncodedPublicKey(publicKey) + + const { onCopy, hasCopied, setValue } = useClipboard("", 2000) + + const onDone = async () => { + navigate(await recover({ showAccountList: true })) + } + + useEffect(() => { + if (signerKey) { + setValue(signerKey) + } + }, [setValue, signerKey]) + + return ( + + + + + +
Share your signer key with the multisig creator
+ + {signerKey ? ( + + {signerKey} + + ) : ( + + )} + + + {signerKey && ( + + )} +
+ + + + +
+ ) +} diff --git a/packages/extension/src/ui/features/multisig/JoinMultisigSettingsScreen.tsx b/packages/extension/src/ui/features/multisig/JoinMultisigSettingsScreen.tsx new file mode 100644 index 000000000..480c7927a --- /dev/null +++ b/packages/extension/src/ui/features/multisig/JoinMultisigSettingsScreen.tsx @@ -0,0 +1,101 @@ +import { + BarBackButton, + ButtonCell, + CellStack, + H6, + NavigationContainer, + P4, + SpacerCell, +} from "@argent/ui" +import { Center, Flex, Image, useDisclosure } from "@chakra-ui/react" +import React, { FC, useCallback } from "react" +import { useNavigate, useParams } from "react-router-dom" + +import { hidePendingMultisig } from "../../../shared/multisig/utils/pendingMultisig" +import { routes, useReturnTo } from "../../routes" +import { getNetworkAccountImageUrl } from "../accounts/accounts.service" +import { useCurrentNetwork } from "../networks/hooks/useCurrentNetwork" +import { usePendingMultisig } from "./multisig.state" +import { MultisigHideModal } from "./MultisigDeleteModal" + +export const JoinMultisigSettingsScreen: FC = () => { + const currentNetwork = useCurrentNetwork() + const { publicKey = "" } = useParams<{ publicKey: string }>() + const navigate = useNavigate() + const returnTo = useReturnTo() + const pendingMultisig = usePendingMultisig({ + publicKey: publicKey, + networkId: currentNetwork.id, + }) + const accountName = pendingMultisig + ? pendingMultisig.name + : "Unnamed Multisig" + + const onClose = useCallback(() => { + if (returnTo) { + navigate(returnTo) + } else { + navigate(-1) + } + }, [navigate, returnTo]) + + const { + isOpen: isDeleteModalOpen, + onOpen: onDeleteModalOpen, + onClose: onDeleteModalClose, + } = useDisclosure() + + const onHideConfirm = useCallback(async () => { + if (pendingMultisig) { + await hidePendingMultisig(pendingMultisig) + onDeleteModalClose() + navigate(routes.accounts()) + } + }, [navigate, onDeleteModalClose, pendingMultisig]) + + return ( + <> + } + title={accountName} + > +
+ +
+ + +
{accountName}
+ + Awaiting owner to finish setup + +
+ + + + Hide account + +
+
+ + + ) +} diff --git a/packages/extension/src/ui/features/multisig/Multisig.ts b/packages/extension/src/ui/features/multisig/Multisig.ts new file mode 100644 index 000000000..945036f8e --- /dev/null +++ b/packages/extension/src/ui/features/multisig/Multisig.ts @@ -0,0 +1,115 @@ +import { PendingMultisig } from "../../../shared/multisig/types" +import { getNetwork } from "../../../shared/network" +import { + BaseMultisigWalletAccount, + MultisigData, +} from "../../../shared/wallet.model" +import { createNewMultisigAccount } from "../../services/backgroundMultisigs" +import { Account, AccountConstructorProps } from "../accounts/Account" + +export interface MultisigConstructorProps extends AccountConstructorProps { + signers: string[] + threshold: number + creator?: string // Creator is the public key of the account that created the multisig account + publicKey: string +} + +export const ZERO_MULTISIG: MultisigData = { + signers: [], + threshold: 0, + creator: undefined, + publicKey: "0x0", +} + +export class Multisig extends Account { + signers: string[] + threshold: number + creator?: string + publicKey: string + + constructor(props: MultisigConstructorProps) { + super(props) + this.signers = props.signers + this.threshold = props.threshold + this.creator = props.creator + this.publicKey = props.publicKey + } + + // Create Method Overload + public static async create(networkId: string): Promise + public static async create( + networkId: string, + providedMultisigData: MultisigData, + ): Promise + + public static async create( + networkId: string, + providedMultisigData?: MultisigData, + ): Promise + + /** + * Create a new multisig account + * If multisigPayload is provided, it will be used to create the multisig account + * If not, a new multisig account will be created with a 0 signers and a threshold of 0 + * This is useful for when you want to "join" a multisig account and the creator has not yet activated the account + * Once the account is activated, update the account with the correct signers and threshold by fetching from the backend + * + * + * @param {string} networkId + * @param {MultisigData} providedMultisigData? + * @returns Promise + */ + public static async create( + networkId: string, + providedMultisigData?: MultisigData, + ): Promise { + const multisigPayload = providedMultisigData || ZERO_MULTISIG + + const result = await createNewMultisigAccount(networkId, multisigPayload) + if (result === "error") { + throw new Error(result) + } + + const network = await getNetwork(networkId) + + if (!network) { + throw new Error(`Network ${networkId} not found`) + } + + return new Multisig({ + name: result.account.name, + address: result.account.address, + network, + signer: result.account.signer, + guardian: result.account.guardian, + escape: result.account.escape, + needsDeploy: result.account.needsDeploy, + type: "multisig", + signers: multisigPayload.signers, + threshold: multisigPayload.threshold, + creator: multisigPayload.creator, + publicKey: multisigPayload.publicKey, + }) + } + + public toBaseMultisigAccount(): BaseMultisigWalletAccount { + const { networkId, address, signers, threshold, publicKey } = this + return { + networkId, + address, + signers, + threshold, + publicKey, + } + } + + public isZeroMultisig(): boolean { + return this.signers.length === 0 && this.threshold === 0 + } +} + +export const multisigIsPending = ( + multisig: Account | PendingMultisig, +): multisig is PendingMultisig => { + return "publicKey" in multisig && !("address" in multisig) +} diff --git a/packages/extension/src/ui/features/multisig/MultisigAddOwnersScreen.tsx b/packages/extension/src/ui/features/multisig/MultisigAddOwnersScreen.tsx new file mode 100644 index 000000000..4de3fb8cc --- /dev/null +++ b/packages/extension/src/ui/features/multisig/MultisigAddOwnersScreen.tsx @@ -0,0 +1,88 @@ +import { H4, P3 } from "@argent/ui" +import { Box, Button, Divider, Flex } from "@chakra-ui/react" +import { FC, useState } from "react" +import { FormProvider, useFormContext } from "react-hook-form" + +import { Account } from "../accounts/Account" +import { useEncodedPublicKeys, useSignerKey } from "../accounts/usePublicKey" +import { useRouteAccount } from "../shield/useRouteAccount" +import { AddOwnersForm } from "./AddOwnerForm" +import { + FieldValuesCreateMultisigForm, + useCreateMultisigForm, +} from "./hooks/useCreateMultisigForm" +import { useMultisig } from "./multisig.state" +import { MultisigConfirmationsWithOwners } from "./MultisigConfirmationsScreen" +import { MultisigSettingsWrapper } from "./MultisigSettingsWrapper" + +export const MultisigAddOwnersScreen: FC = () => { + const account = useRouteAccount() + const signerKey = useSignerKey() + const methods = useCreateMultisigForm(signerKey) + const [step, setStep] = useState(0) + const [goBack, setGoBack] = useState void)>(undefined) + const goNext = () => { + setGoBack(() => () => setStep((step) => step - 1)) + setStep((step) => step + 1) + } + + return ( + + + {account && ( + <> + {step === 0 && ( + + )} + {step === 1 && ( + + )} + + )} + + + ) +} + +const MultisigAddOwners = ({ + account, + goNext, +}: { + account: Account + goNext: () => void +}) => { + const multisig = useMultisig(account) + const { trigger } = useFormContext() + + const signerKeys = useEncodedPublicKeys(multisig?.signers ?? []) + const handleNavigationToConfirmationScreen = async () => { + const isValid = await trigger("signerKeys") + if (isValid) { + goNext() + } + } + return ( + + + +

Add owners

+ + Ask your co-owners to go to “Join existing multisig” in Argent X and + send you their signer key + + + A signer key is NOT an account address + + + +
+ +
+
+ ) +} diff --git a/packages/extension/src/ui/features/multisig/MultisigBanner.tsx b/packages/extension/src/ui/features/multisig/MultisigBanner.tsx new file mode 100644 index 000000000..7470c7a4d --- /dev/null +++ b/packages/extension/src/ui/features/multisig/MultisigBanner.tsx @@ -0,0 +1,58 @@ +import { AlertButton } from "@argent/ui" +import { icons } from "@argent/ui" +import { Spinner } from "@chakra-ui/react" +import { BigNumber } from "ethers" +import { FC, useCallback, useMemo } from "react" + +import { accountService } from "../../../shared/account/service" +import { useIsMultisigDeploying } from "./hooks/useIsMultisigDeploying" +import { Multisig } from "./Multisig" + +const { MultisigIcon } = icons + +export const MultisigBanner: FC<{ + multisig: Multisig + feeTokenBalance?: BigNumber +}> = ({ multisig, feeTokenBalance }) => { + const isMultisigDeploying = useIsMultisigDeploying(multisig) + + const showActivateMultisigBanner = useMemo( + () => + !isMultisigDeploying && multisig.needsDeploy && feeTokenBalance?.gt(0), + [feeTokenBalance, isMultisigDeploying, multisig.needsDeploy], + ) + + const onActivateMultisig = useCallback(async () => { + if (multisig) { + await accountService.deploy(multisig) + } + }, [multisig]) + + if (showActivateMultisigBanner) { + return ( + } + colorScheme="primary" + bg="primaryExtraDark.500" + onClick={onActivateMultisig} + /> + ) + } + + if (isMultisigDeploying) { + return ( + } + /> + ) + } + + return null +} diff --git a/packages/extension/src/ui/features/multisig/MultisigConfirmationsScreen.tsx b/packages/extension/src/ui/features/multisig/MultisigConfirmationsScreen.tsx new file mode 100644 index 000000000..8b220316d --- /dev/null +++ b/packages/extension/src/ui/features/multisig/MultisigConfirmationsScreen.tsx @@ -0,0 +1,183 @@ +import { H1, H4, P3 } from "@argent/ui" +import { Box, Button, Center, Flex } from "@chakra-ui/react" +import { isEmpty } from "lodash-es" +import { FC } from "react" +import { FormProvider, useFormContext } from "react-hook-form" +import { useNavigate } from "react-router-dom" + +import { routes } from "../../routes" +import { + addMultisigOwners, + updateMultisigThreshold, +} from "../../services/backgroundMultisigs" +import { Account } from "../accounts/Account" +import { useRouteAccount } from "../shield/useRouteAccount" +import { FieldValuesCreateMultisigForm } from "./hooks/useCreateMultisigForm" +import { + FieldValuesThresholdForm, + useUpdateThresholdForm, +} from "./hooks/useUpdateThreshold" +import { useMultisig } from "./multisig.state" +import { MultisigSettingsWrapper } from "./MultisigSettingsWrapper" +import { SetConfirmationsInput } from "./SetConfirmationsInput" + +export const MultisigConfirmationsScreen: FC = () => { + const account = useRouteAccount() + return ( + + {account && } + + ) +} + +const MultisigConfirmationsWithFormProvider = ({ + account, +}: { + account: Account +}) => { + const multisig = useMultisig(account) + + const methods = useUpdateThresholdForm(multisig?.threshold) + return ( + + + + ) +} +export const MultisigConfirmationsWithOwners = ({ + account, +}: { + account: Account +}) => { + const multisig = useMultisig(account) + const navigate = useNavigate() + + const { + trigger, + formState: { errors }, + getValues, + } = useFormContext() + + const handleNextClick = async () => { + trigger() + if (isEmpty(errors)) { + await addMultisigOwners({ + address: account.address, + newThreshold: getValues("confirmations"), + signersToAdd: getValues("signerKeys").map((signer) => signer.key), + currentThreshold: multisig?.threshold, + }) + + navigate(routes.accountActivity()) + } + } + const totalSigners = multisig?.signers + ? multisig.signers.length + getValues("signerKeys").length + : getValues("signerKeys").length + + return ( + + ) +} + +export const MultisigConfirmationsWithoutOwners = ({ + account, +}: { + account: Account +}) => { + const multisig = useMultisig(account) + + const { + trigger, + formState: { errors }, + getValues, + } = useFormContext() + + const handleNextClick = () => { + trigger() + const newThreshold = getValues("confirmations") + if (!Object.keys(errors).length && newThreshold !== multisig?.threshold) { + updateMultisigThreshold({ + address: account.address, + newThreshold: getValues("confirmations"), + }) + } + } + + return ( + + ) +} + +export const BaseMultisigConfirmations = ({ + account, + handleNextClick, + totalSigners, + buttonTitle = "Next", +}: { + account: Account + handleNextClick: () => void | Promise + totalSigners?: number + buttonTitle?: string +}) => { + const multisig = useMultisig(account) + + return ( + + +

Set confirmations

+ + How many owners must confirm each transaction before it's sent? + +
+ {account.needsDeploy ? ( + + +
+

{multisig?.threshold}

+
+
+
+ + out of {multisig?.signers.length} owners + +
+
+ ) : ( + + + + + )} +
+ ) +} diff --git a/packages/extension/src/ui/features/multisig/MultisigDeleteModal.tsx b/packages/extension/src/ui/features/multisig/MultisigDeleteModal.tsx new file mode 100644 index 000000000..bd42448a5 --- /dev/null +++ b/packages/extension/src/ui/features/multisig/MultisigDeleteModal.tsx @@ -0,0 +1,62 @@ +import { Button, H5, P3 } from "@argent/ui" +import { + Modal, + ModalBody, + ModalContent, + ModalFooter, + ModalHeader, + ModalOverlay, +} from "@chakra-ui/react" +import { FC, MouseEvent } from "react" + +interface MultisigHideModalProps { + isOpen: boolean + onHide?: (e: MouseEvent) => void | Promise + onClose: () => void + multisigType: "pending" | "active" +} + +export const MultisigHideModal: FC = ({ + isOpen, + onHide, + onClose, + multisigType, +}) => { + return ( + + + + +
+ Are you sure? +
+
+ + + {multisigType === "pending" && ( + <> + The multisig owner can still add you to the multisig if you + shared your signer key with them + + )} + {multisigType === "active" && ( + <> + You can still be added to the multisig in the future. You can + always unhide this account from the account list screen. + + )} + + + + + + + +
+
+ ) +} diff --git a/packages/extension/src/ui/features/multisig/MultisigListAccounts.tsx b/packages/extension/src/ui/features/multisig/MultisigListAccounts.tsx new file mode 100644 index 000000000..a0a8baa3c --- /dev/null +++ b/packages/extension/src/ui/features/multisig/MultisigListAccounts.tsx @@ -0,0 +1,51 @@ +import { isEmpty } from "lodash-es" +import { FC, useMemo } from "react" + +import { PendingMultisig } from "../../../shared/multisig/types" +import { multisigAndAccountSort } from "../../../shared/utils/accountsMultisigSort" +import { BaseWalletAccount } from "../../../shared/wallet.model" +import { Account } from "../accounts/Account" +import { AccountListScreenItemContainer } from "../accounts/AccountListScreenItemContainer" +import { multisigIsPending } from "./Multisig" +import { PendingMultisigListScreenItem } from "./PendingMultisigListScreenItem" + +export interface MultisigListAccountsProps { + accounts: Account[] + pendingMultisigs: PendingMultisig[] + selectedAccount?: BaseWalletAccount + returnTo?: string +} + +export const MultisigListAccounts: FC = ({ + accounts, + pendingMultisigs, + selectedAccount, + returnTo, +}) => { + const multisigsOrAccounts = useMemo(() => { + if (pendingMultisigs && !isEmpty(pendingMultisigs)) { + return multisigAndAccountSort(pendingMultisigs, accounts) + } + return accounts + }, [accounts, pendingMultisigs]) + + return ( + <> + {multisigsOrAccounts.map((multisig) => + multisigIsPending(multisig) ? ( + + ) : ( + + ), + )} + + ) +} diff --git a/packages/extension/src/ui/features/multisig/MultisigOwnersScreen.tsx b/packages/extension/src/ui/features/multisig/MultisigOwnersScreen.tsx new file mode 100644 index 000000000..7bd82b2b4 --- /dev/null +++ b/packages/extension/src/ui/features/multisig/MultisigOwnersScreen.tsx @@ -0,0 +1,110 @@ +import { B2, H4, H6, P3, icons } from "@argent/ui" +import { Box, Button, Divider, Flex, IconButton } from "@chakra-ui/react" +import { FC } from "react" +import { useNavigate } from "react-router-dom" + +import { routes } from "../../routes" +import { formatTruncatedSignerKey } from "../../services/addresses" +import { Account } from "../accounts/Account" +import { useEncodedPublicKeys, useSignerKey } from "../accounts/usePublicKey" +import { useRouteAccount } from "../shield/useRouteAccount" +import { useMultisig } from "./multisig.state" +import { MultisigSettingsWrapper } from "./MultisigSettingsWrapper" + +const { MultisigJoinIcon, MinusIcon } = icons + +export const MultisigOwnersScreen: FC = () => { + const account = useRouteAccount() + + return ( + + {account && } + + ) +} + +const MultisigOwners = ({ account }: { account: Account }) => { + const multisig = useMultisig(account) + + const signerKey = useSignerKey() + const signerKeys = useEncodedPublicKeys(multisig?.signers ?? []) + const navigate = useNavigate() + + const handleAddOwnerClick = () => { + navigate(routes.multisigAddOwners(account.address)) + } + + const handleRemoveOwnerClick = (signerToRemove: string) => { + navigate(routes.multisigRemoveOwners(account.address, signerToRemove)) + } + + return ( + + + +

{multisig?.signers.length} owners

+ + {multisig?.threshold}/{multisig?.signers.length} owners must confirm + each transactions + + + + Me + + + {signerKey && ( +
{formatTruncatedSignerKey(signerKey)}
+ )} +
+ + Other owners + + {signerKeys + .filter((signer) => signer !== signerKey) + .map((signer) => { + return ( + +
{formatTruncatedSignerKey(signer)}
+ handleRemoveOwnerClick(signer)} + aria-label={"Remove owner"} + icon={} + h="auto" + minH={0} + minW={0} + p={1.5} + borderRadius="full" + /> +
+ ) + })} +
+ {!account.needsDeploy && ( + + )} +
+
+ ) +} diff --git a/packages/extension/src/ui/features/multisig/MultisigPendingTransactionDetailsScreen.tsx b/packages/extension/src/ui/features/multisig/MultisigPendingTransactionDetailsScreen.tsx new file mode 100644 index 000000000..b573f0f73 --- /dev/null +++ b/packages/extension/src/ui/features/multisig/MultisigPendingTransactionDetailsScreen.tsx @@ -0,0 +1,233 @@ +import { + BarBackButton, + H5, + H6, + NavigationContainer, + P3, + StickyGroup, +} from "@argent/ui" +import { Box, Button, Flex, useDisclosure } from "@chakra-ui/react" +import { useEffect, useMemo, useState } from "react" +import Measure from "react-measure" +import { Navigate, useNavigate } from "react-router-dom" + +import { setHasSeenTransaction } from "../../../shared/multisig/pendingTransactionsStore" +import { useAppState } from "../../app.state" +import { routes, useRouteRequestId } from "../../routes" +import { formatTruncatedAddress } from "../../services/addresses" +import { addMultisigTransactionSignature } from "../../services/backgroundMultisigs" +import { transformTransaction } from "../accountActivity/transform" +import { getTransactionFromPendingMultisigTransaction } from "../accountActivity/transform/transaction/transformers/pendingMultisigTransactionAdapter" +import { TransactionIcon } from "../accountActivity/ui/TransactionIcon" +import { useSelectedAccount } from "../accounts/accounts.state" +import { usePublicKey } from "../accounts/usePublicKey" +import { FeeEstimationContainer } from "../actions/feeEstimation/FeeEstimationContainer" +import { AccountNetworkInfo } from "../actions/transaction/ApproveTransactionScreen/AccountNetworkInfo" +import { MultisigBanner } from "../actions/transaction/ApproveTransactionScreen/MultisigBanner" +import { TransactionActions } from "../actions/transaction/ApproveTransactionScreen/TransactionActions" +import { useMultisig } from "./multisig.state" +import { MultisigPendingTxModal } from "./MultisigPendingTxModal" +import { + useMultisigPendingTransaction, + useMultisigPendingTransactionsByAccount, +} from "./multisigTransactions.state" + +export const MultisigPendingTransactionDetailsScreen = () => { + const [confirmButtonDisabled, setConfirmButtonDisabled] = useState(false) + + const selectedAccount = useSelectedAccount() + const publicKey = usePublicKey(selectedAccount) + + const requestId = useRouteRequestId() + const pendingTransaction = useMultisigPendingTransaction(requestId) + const navigate = useNavigate() + const [placeholderHeight, setPlaceholderHeight] = useState(100) + + const { + isOpen: isMultisigModalOpen, + onOpen: onMultisigModalOpen, + onClose: onMultisigModalClose, + } = useDisclosure() + + const multisig = useMultisig(selectedAccount) + + const pendingMultisigTransactions = + useMultisigPendingTransactionsByAccount(selectedAccount) + + const transactionTransformed = useMemo(() => { + if (pendingTransaction && selectedAccount) { + return transformTransaction({ + transaction: getTransactionFromPendingMultisigTransaction( + pendingTransaction, + selectedAccount, + ), + accountAddress: selectedAccount.address, + }) + } + }, [pendingTransaction, selectedAccount]) + + const needsApproval = useMemo(() => { + if (pendingTransaction && publicKey) { + return pendingTransaction.nonApprovedSigners.includes(publicKey) + } + + return false + }, [pendingTransaction, publicKey]) + + const transactions = useMemo(() => { + if (pendingTransaction) { + return pendingTransaction.transaction.calls + } + return [] + }, [pendingTransaction]) + + useEffect(() => { + if (requestId && pendingTransaction?.notify) { + setHasSeenTransaction(requestId) + } + }, [pendingTransaction, requestId]) + + if (!selectedAccount || !requestId) { + return + } + const goToTransactionsConfirmations = () => { + navigate( + routes.multisigPendingTransactionConfirmations( + selectedAccount.address, + requestId, + ), + ) + } + + const onConfirm = async () => { + useAppState.setState({ isLoading: true }) + const txHash = await addMultisigTransactionSignature(requestId) + useAppState.setState({ isLoading: false }) + if (txHash) { + return navigate(-1) + } + } + + const onReject = () => { + return navigate(-1) + } + + const title = ( + +
{selectedAccount.name}
+ + ({formatTruncatedAddress(selectedAccount.address)}) + +
+ ) + + return ( + navigate(-1)} />} + title={title} + > + { + e.preventDefault() + if (pendingMultisigTransactions.length > 1) { + onMultisigModalOpen() + } else { + onConfirm() + } + }} + > + + + {transactionTransformed && ( + + )} + +
{transactionTransformed?.displayName}
+
+
+ {selectedAccount && pendingTransaction && ( + + )} +
+ + + + + + + + + + + { + const { height = 100 } = contentRect.bounds || {} + setPlaceholderHeight(height) + }} + > + {({ measureRef }) => ( + + {needsApproval && ( + <> + + + + + + + )} + + )} + + + + {multisig && isMultisigModalOpen && ( + + )} +
+ ) +} diff --git a/packages/extension/src/ui/features/multisig/MultisigPendingTxModal.tsx b/packages/extension/src/ui/features/multisig/MultisigPendingTxModal.tsx new file mode 100644 index 000000000..05dbd1717 --- /dev/null +++ b/packages/extension/src/ui/features/multisig/MultisigPendingTxModal.tsx @@ -0,0 +1,57 @@ +import { Button, H5, P3 } from "@argent/ui" +import { + Modal, + ModalBody, + ModalContent, + ModalFooter, + ModalHeader, + ModalOverlay, +} from "@chakra-ui/react" +import { FC, MouseEvent } from "react" + +interface MultisigPendingTxModalProps { + isOpen: boolean + onConfirm: (e: MouseEvent) => void + onClose: () => void + noOfOwners: number +} + +export const MultisigPendingTxModal: FC = ({ + isOpen, + onConfirm, + onClose, + noOfOwners, +}) => { + return ( + + + + +
+ Multiple pending transactions, only one can be valid +
+
+ + + The first transaction to be confirmed by {noOfOwners} owners will be + valid.The rest will get cancelled + + + + + + + +
+
+ ) +} diff --git a/packages/extension/src/ui/features/multisig/MultisigRemoveOwnerScreen.tsx b/packages/extension/src/ui/features/multisig/MultisigRemoveOwnerScreen.tsx new file mode 100644 index 000000000..b3a291c2b --- /dev/null +++ b/packages/extension/src/ui/features/multisig/MultisigRemoveOwnerScreen.tsx @@ -0,0 +1,92 @@ +import { FC, useMemo } from "react" +import { FormProvider, useFormContext } from "react-hook-form" +import { useNavigate } from "react-router-dom" + +import { routes, useRouteSignerToRemove } from "../../routes" +import { removeMultisigOwner } from "../../services/backgroundMultisigs" +import { Account } from "../accounts/Account" +import { useRouteAccount } from "../shield/useRouteAccount" +import { + FieldValuesThresholdForm, + useUpdateThresholdForm, +} from "./hooks/useUpdateThreshold" +import { useMultisig } from "./multisig.state" +import { BaseMultisigConfirmations } from "./MultisigConfirmationsScreen" +import { MultisigSettingsWrapper } from "./MultisigSettingsWrapper" + +export const MultisigRemoveOwnersScreen: FC = () => { + const account = useRouteAccount() + const signerToRemove = useRouteSignerToRemove() + + return ( + + {account && signerToRemove && ( + + )} + + ) +} + +const MultisigRemoveOwnerAccountWrapper = ({ + account, + signerToRemove, +}: { + account: Account + signerToRemove: string +}) => { + const multisig = useMultisig(account) + + const newTotalSigners = useMemo( + () => (multisig?.signers.length ? multisig.signers.length - 1 : undefined), + [multisig?.signers], + ) + const methods = useUpdateThresholdForm(newTotalSigners) + + return ( + + + + ) +} + +const MultisigRemove = ({ + account, + signerToRemove, + totalSigners, +}: { + account: Account + signerToRemove: string + totalSigners?: number +}) => { + const { trigger, getValues } = useFormContext() + + const navigate = useNavigate() + + const handleSubmit = async () => { + const isValid = await trigger() + const newThreshold = getValues("confirmations") + if (isValid && signerToRemove && account?.address) { + await removeMultisigOwner({ + signerToRemove, + newThreshold, + address: account?.address, + }) + navigate(routes.accountActivity()) + } + } + + return ( + + ) +} diff --git a/packages/extension/src/ui/features/multisig/MultisigSettingsWrapper.tsx b/packages/extension/src/ui/features/multisig/MultisigSettingsWrapper.tsx new file mode 100644 index 000000000..b2f40dac0 --- /dev/null +++ b/packages/extension/src/ui/features/multisig/MultisigSettingsWrapper.tsx @@ -0,0 +1,31 @@ +import { BarBackButton, NavigationContainer } from "@argent/ui" +import { FC, PropsWithChildren, ReactNode, useCallback } from "react" +import { useNavigate } from "react-router-dom" + +import { useRouteAccount } from "../shield/useRouteAccount" + +export const MultisigSettingsWrapper: FC< + PropsWithChildren & { goBack?: () => void } +> = ({ children, goBack }: { children?: ReactNode; goBack?: () => void }) => { + const navigate = useNavigate() + const account = useRouteAccount() + + const onClose = useCallback(() => { + if (goBack) { + goBack() + } else { + navigate(-1) + } + }, [navigate, goBack]) + + return ( + <> + } + title={account?.name} + > + {children} + + + ) +} diff --git a/packages/extension/src/ui/features/multisig/MultisigTransactionConfirmationsScreen.tsx b/packages/extension/src/ui/features/multisig/MultisigTransactionConfirmationsScreen.tsx new file mode 100644 index 000000000..187ae45d0 --- /dev/null +++ b/packages/extension/src/ui/features/multisig/MultisigTransactionConfirmationsScreen.tsx @@ -0,0 +1,136 @@ +import { + BarBackButton, + H4, + H6, + NavigationContainer, + P3, + icons, +} from "@argent/ui" +import { Box, Divider, Flex } from "@chakra-ui/react" +import { useMemo } from "react" + +import { useRouteRequestId } from "../../routes" +import { formatTruncatedSignerKey } from "../../services/addresses" +import { transformTransaction } from "../accountActivity/transform" +import { getTransactionFromPendingMultisigTransaction } from "../accountActivity/transform/transaction/transformers/pendingMultisigTransactionAdapter" +import { Account } from "../accounts/Account" +import { useEncodedPublicKeys, useSignerKey } from "../accounts/usePublicKey" +import { useRouteAccount } from "../shield/useRouteAccount" +import { useMultisig } from "./multisig.state" +import { useMultisigPendingTransaction } from "./multisigTransactions.state" + +const { TickIcon } = icons + +export const MultisigTransactionConfirmationsScreen = () => { + const selectedAccount = useRouteAccount() + + const requestId = useRouteRequestId() + const pendingTransaction = useMultisigPendingTransaction(requestId) + const signerKey = useSignerKey() + + const transactionTransformed = useMemo(() => { + if (pendingTransaction && selectedAccount) { + return transformTransaction({ + transaction: getTransactionFromPendingMultisigTransaction( + pendingTransaction, + selectedAccount, + ), + accountAddress: selectedAccount.address, + }) + } + }, [pendingTransaction, selectedAccount]) + const approvedSignersPublicKey = useEncodedPublicKeys( + pendingTransaction?.approvedSigners ?? [], + ) + + const nonApprovedSignersPublicKey = useEncodedPublicKeys( + pendingTransaction?.nonApprovedSigners ?? [], + ) + return ( + } + title={transactionTransformed?.displayName} + > + +

Waiting for confirmations...

+ {selectedAccount && pendingTransaction && ( + + )} + + + + Me + + + {signerKey && ( + +
{formatTruncatedSignerKey(signerKey)}
+ {approvedSignersPublicKey.some((key) => key === signerKey) && ( + + )} +
+ )} +
+ + Other owners + + {approvedSignersPublicKey + .filter((signer) => signer !== signerKey) + .map((signer) => { + return ( + + +
{formatTruncatedSignerKey(signer)}
+ +
+
+ ) + })} + {nonApprovedSignersPublicKey + .filter((signer) => signer !== signerKey) + .map((signer) => { + return ( + +
{formatTruncatedSignerKey(signer)}
+
+ ) + })} +
+
+ ) +} + +const TransactionConfirmationsScreenSubtitle = ({ + account, + approvedSignersLength, +}: { + account: Account + approvedSignersLength: number +}) => { + const multisig = useMultisig(account) + + const missingConfirmationsMessage = useMemo(() => { + if (multisig?.threshold) { + const missingConfirmations = multisig?.threshold - approvedSignersLength + return `${missingConfirmations} more confirmation${ + missingConfirmations > 1 ? "s" : "" + } required` + } + }, [approvedSignersLength, multisig?.threshold]) + return {missingConfirmationsMessage} +} diff --git a/packages/extension/src/ui/features/multisig/NewMultisigScreen.tsx b/packages/extension/src/ui/features/multisig/NewMultisigScreen.tsx new file mode 100644 index 000000000..41e047780 --- /dev/null +++ b/packages/extension/src/ui/features/multisig/NewMultisigScreen.tsx @@ -0,0 +1,144 @@ +import { + BarCloseButton, + H6, + NavigationContainer, + P4, + icons, + logos, +} from "@argent/ui" +import { Center, Flex } from "@chakra-ui/react" +import { FC, useCallback } from "react" +import { useNavigate } from "react-router-dom" + +import { urlWithQuery } from "../../../shared/utils/url" +import { useAppState } from "../../app.state" +import { CustomButtonCell } from "../../components/CustomButtonCell" +import { routes } from "../../routes" +import { assertNever } from "../../services/assertNever" +import { useCreatePendingMultisig } from "./hooks/useCreatePendingMultisig" + +const { AddIcon, MultisigJoinIcon } = icons +const { MultisigDiagram } = logos + +type MultisigOptionType = "create" | "join" + +interface MultisigOption { + title: string + subtitle?: string + type: MultisigOptionType + icon: React.ReactNode +} + +const multisigOptions: MultisigOption[] = [ + { + title: "Create new multisig", + type: "create", + icon: , + }, + { + title: "Join existing multisig", + subtitle: "Create a new signer key", + type: "join", + icon: , + }, +] + +export const NewMultisigScreen: FC = () => { + const navigate = useNavigate() + const { switcherNetworkId } = useAppState() + const { createPendingMultisig } = useCreatePendingMultisig() + + const onClick = useCallback( + async (type: MultisigOptionType) => { + switch (type) { + case "create": { + const url = urlWithQuery("index.html", { + goto: "multisig", + networkId: switcherNetworkId, + }) + chrome.tabs.create({ + url, + }) + navigate(routes.accounts()) + break + } + + case "join": { + // Initialize the multisig account with a zero multisig + const pendingMultisig = await createPendingMultisig(switcherNetworkId) + if (pendingMultisig) { + navigate(routes.multisigJoin(pendingMultisig.publicKey)) + } + break + } + + default: + assertNever(type) + } + }, + [switcherNetworkId, createPendingMultisig, navigate], + ) + + return ( + navigate(-1)} />} + title="Multisig account" + > + + + + + A multisig allows multiple owners to manage an account by requiring + multiple confirmations for a transaction + + + {multisigOptions.map((option, index) => ( + onClick(option.type)} + _hover={{ + backgroundColor: "neutrals.700", + "& > .icon-wrapper": { + backgroundColor: "neutrals.600", + }, + }} + > + +
+ {option.icon} +
+ +
{option.title}
+ {option.subtitle && ( + + {option.subtitle} + + )} +
+
+
+ ))} +
+
+ ) +} diff --git a/packages/extension/src/ui/features/multisig/PendingMultisigListItem.tsx b/packages/extension/src/ui/features/multisig/PendingMultisigListItem.tsx new file mode 100644 index 000000000..5135e8a79 --- /dev/null +++ b/packages/extension/src/ui/features/multisig/PendingMultisigListItem.tsx @@ -0,0 +1,94 @@ +import { H6, P4, icons, typographyStyles } from "@argent/ui" +import { Circle, Flex, Text, chakra } from "@chakra-ui/react" +import { FC } from "react" + +import { + CustomButtonCell, + CustomButtonCellProps, +} from "../../components/CustomButtonCell" +import { formatTruncatedAddress } from "../../services/addresses" +import { AccountAvatar } from "../accounts/AccountAvatar" +import { getNetworkAccountImageUrl } from "../accounts/accounts.service" + +const { ViewIcon } = icons + +export interface PendingMultisigListItemProps extends CustomButtonCellProps { + accountName: string + publicKey: string + networkId: string + networkName?: string + hidden?: boolean + avatarOutlined?: boolean +} + +export const NetworkStatusWrapper = chakra(Flex, { + baseStyle: { + alignItems: "center", + justifyContent: "right", + gap: 1, + ml: 1, + pointerEvents: "none", + ...typographyStyles.L1, + }, +}) + +export const PendingMultisigListItem: FC = ({ + accountName, + publicKey, + networkId, + networkName, + hidden, + avatarOutlined, + children, + ...rest +}) => { + return ( + + + ) +} diff --git a/packages/extension/src/ui/features/multisig/PendingMultisigListScreenItem.tsx b/packages/extension/src/ui/features/multisig/PendingMultisigListScreenItem.tsx new file mode 100644 index 000000000..40ab00d2b --- /dev/null +++ b/packages/extension/src/ui/features/multisig/PendingMultisigListScreenItem.tsx @@ -0,0 +1,86 @@ +import { Button, icons } from "@argent/ui" +import { Circle, Flex } from "@chakra-ui/react" +import { FC, MouseEvent, useCallback, useRef } from "react" +import { useNavigate } from "react-router-dom" + +import { PendingMultisig } from "../../../shared/multisig/types" +import { routes } from "../../routes" +import { AccountListScreenItemAccessory } from "../accounts/AccountListScreenItemAccessory" +import { PendingMultisigListItem } from "./PendingMultisigListItem" + +const { ChevronRightIcon, MoreIcon } = icons + +/** TODO: refactor - this should use AccoutListScreenItem */ + +export interface IPendingMultisigListScreenItem { + pendingMultisig: PendingMultisig + clickNavigateSettings?: boolean +} + +export const PendingMultisigListScreenItem: FC< + IPendingMultisigListScreenItem +> = ({ pendingMultisig, clickNavigateSettings }) => { + const navigate = useNavigate() + const mouseDownSettings = useRef(false) + + const onClick = useCallback( + (e: MouseEvent) => { + e.stopPropagation() + if (clickNavigateSettings || mouseDownSettings.current) { + navigate(routes.multisigJoinSettings(pendingMultisig.publicKey)) + } else { + navigate(routes.multisigJoin(pendingMultisig.publicKey)) + } + }, + [clickNavigateSettings, navigate, pendingMultisig.publicKey], + ) + + // TODO: Implement onOptionsClick + + return ( + + { + e.stopPropagation() + mouseDownSettings.current = false + }} + onClick={onClick} + accountName={pendingMultisig.name} + publicKey={pendingMultisig.publicKey} + networkId={pendingMultisig.networkId} + pr={14} + > + {clickNavigateSettings && ( + + + + )} + {!clickNavigateSettings && ( + + + + )} + + + ) +} diff --git a/packages/extension/src/ui/features/multisig/RemoveMultisigSettingScreen.test.tsx b/packages/extension/src/ui/features/multisig/RemoveMultisigSettingScreen.test.tsx new file mode 100644 index 000000000..cb89e8757 --- /dev/null +++ b/packages/extension/src/ui/features/multisig/RemoveMultisigSettingScreen.test.tsx @@ -0,0 +1,44 @@ +import { fireEvent, render, screen } from "@testing-library/react" + +import { Multisig } from "./Multisig" +import { getMockMultisig } from "./multisig.mock" +import { RemovedMultisigSettingsScreen } from "./RemovedMultisigSettingsScreen" + +const mockMultisig = { + ...getMockMultisig({ name: "Test Multisig" }), +} as Multisig + +test("renders account name", () => { + render( + null} + onHideMultisigModalClose={() => null} + isHideMultisigModalOpen={false} + onHideConfirm={() => undefined} + />, + ) + const accountNameElement = screen.getByText(/Test Account/i) + expect(accountNameElement).toBeInTheDocument() +}) + +test("opens 'Hide account' modal when hide button is clicked", () => { + const handleHideMultisigModalOpen = vi.fn() + + render( + null} + isHideMultisigModalOpen={false} + onHideConfirm={() => undefined} + />, + ) + + const hideButtonElement = screen.getByText(/Hide account/i) + fireEvent.click(hideButtonElement) + + expect(handleHideMultisigModalOpen).toHaveBeenCalledTimes(1) +}) diff --git a/packages/extension/src/ui/features/multisig/RemovedMultisigSettingsScreen.tsx b/packages/extension/src/ui/features/multisig/RemovedMultisigSettingsScreen.tsx new file mode 100644 index 000000000..2f0adb6f6 --- /dev/null +++ b/packages/extension/src/ui/features/multisig/RemovedMultisigSettingsScreen.tsx @@ -0,0 +1,68 @@ +import { ButtonCell, CellStack, H6, P4, SpacerCell } from "@argent/ui" +import { Center, Flex, Image } from "@chakra-ui/react" +import { FC, MouseEvent } from "react" + +import { getNetworkAccountImageUrl } from "../accounts/accounts.service" +import { Multisig } from "./Multisig" +import { MultisigHideModal } from "./MultisigDeleteModal" + +export interface RemovedMultisigSettingsScreenProps { + multisig: Multisig + accountName: string + onHideMultisigModalOpen: () => void + onHideMultisigModalClose: () => void + onHideConfirm: (e: MouseEvent) => void | Promise + isHideMultisigModalOpen: boolean +} + +export const RemovedMultisigSettingsScreen: FC< + RemovedMultisigSettingsScreenProps +> = ({ + multisig, + accountName, + onHideMultisigModalClose, + onHideMultisigModalOpen, + isHideMultisigModalOpen, + onHideConfirm, +}) => { + return ( + <> +
+ +
+ + +
{accountName}
+ + You were removed from this multisig + +
+ + + + Hide account + +
+ + + ) +} diff --git a/packages/extension/src/ui/features/multisig/RemovedMultisigSettingsScreenContainer.tsx b/packages/extension/src/ui/features/multisig/RemovedMultisigSettingsScreenContainer.tsx new file mode 100644 index 000000000..42de28070 --- /dev/null +++ b/packages/extension/src/ui/features/multisig/RemovedMultisigSettingsScreenContainer.tsx @@ -0,0 +1,71 @@ +import { BarBackButton, NavigationContainer } from "@argent/ui" +import { useDisclosure } from "@chakra-ui/react" +import { FC, useCallback } from "react" +import { useNavigate } from "react-router-dom" + +import { hideMultisig } from "../../../shared/multisig/utils/baseMultisig" +import { routes, useReturnTo, useRouteAccountAddress } from "../../routes" +import { autoSelectAccountOnNetwork } from "../accounts/switchAccount" +import { useCurrentNetwork } from "../networks/hooks/useCurrentNetwork" +import { useMultisig } from "./multisig.state" +import { RemovedMultisigSettingsScreen } from "./RemovedMultisigSettingsScreen" + +export const RemovedMultisigSettingsScreenContainer: FC = () => { + const currentNetwork = useCurrentNetwork() + const accountAddress = useRouteAccountAddress() + const navigate = useNavigate() + const returnTo = useReturnTo() + const multisig = useMultisig({ + address: accountAddress ?? "", + networkId: currentNetwork.id, + }) + const accountName = multisig ? multisig.name : "Unnamed Multisig" + + const onClose = useCallback(() => { + if (returnTo) { + navigate(returnTo) + } else { + navigate(-1) + } + }, [navigate, returnTo]) + + const { + isOpen: isHideMultisigModalOpen, + onOpen: onHideMultisigModalOpen, + onClose: onHideMultisigModalClose, + } = useDisclosure() + + const onHideConfirm = useCallback(async () => { + if (multisig) { + await hideMultisig(multisig) + const account = await autoSelectAccountOnNetwork(currentNetwork.id) + onHideMultisigModalClose() + if (account) { + navigate(routes.accounts()) + } else { + /** no accounts, return to empty account screen */ + navigate(routes.accountTokens()) + } + } + }, [currentNetwork.id, multisig, navigate, onHideMultisigModalClose]) + + if (!multisig) { + throw new Error("Multisig not found") + } + + return ( + } + title={accountName} + > + + + ) +} diff --git a/packages/extension/src/ui/features/multisig/RemovedMultisigWarningScreen.tsx b/packages/extension/src/ui/features/multisig/RemovedMultisigWarningScreen.tsx new file mode 100644 index 000000000..48a70dafe --- /dev/null +++ b/packages/extension/src/ui/features/multisig/RemovedMultisigWarningScreen.tsx @@ -0,0 +1,16 @@ +import { FC } from "react" + +import { WarningScreen } from "../accounts/WarningScreen" + +export const RemovedMultisigWarningScreen: FC<{ + onReject?: () => void +}> = ({ onReject }) => { + return ( + + ) +} diff --git a/packages/extension/src/ui/features/multisig/SetConfirmationsInput.tsx b/packages/extension/src/ui/features/multisig/SetConfirmationsInput.tsx new file mode 100644 index 000000000..a103aa958 --- /dev/null +++ b/packages/extension/src/ui/features/multisig/SetConfirmationsInput.tsx @@ -0,0 +1,78 @@ +import { FieldError, H1 } from "@argent/ui" +import { icons } from "@argent/ui" +import { Box, Button, Center, Flex } from "@chakra-ui/react" +import { Controller, useFormContext } from "react-hook-form" + +import { FieldValuesThresholdForm } from "./hooks/useUpdateThreshold" + +const { AddIcon, MinusIcon } = icons + +export const SetConfirmationsInput = ({ + existingThreshold, + totalSigners, +}: { + existingThreshold?: number + totalSigners?: number +}) => { + const { + control, + formState: { errors }, + } = useFormContext() + + return ( + + ( + <> +
+ + + +

{field.value}

+ +
+
out of {totalSigners} owners
+
+
+ + )} + /> + {errors.confirmations && ( + {errors.confirmations.message} + )} +
+ ) +} diff --git a/packages/extension/src/ui/features/multisig/hooks/useCreateMultisigForm.ts b/packages/extension/src/ui/features/multisig/hooks/useCreateMultisigForm.ts new file mode 100644 index 000000000..961f62d32 --- /dev/null +++ b/packages/extension/src/ui/features/multisig/hooks/useCreateMultisigForm.ts @@ -0,0 +1,49 @@ +import { zodResolver } from "@hookform/resolvers/zod" +import { useForm } from "react-hook-form" +import { z } from "zod" + +export const confirmationsSchema = z + .number() + .positive() + .min(1, "You need at least one confirmation") + .default(1) + +const getFormSchema = (accountSignerKey?: string) => + z + .object({ + signerKeys: z + .object({ + key: z.string().regex(/^[a-zA-Z0-9]{43}$/, "Incorrect signer key"), + }) + .array() + .min(1, "You need at least one co-owner") + .refine( + (arr) => { + const extendedArr = accountSignerKey + ? [...arr.map((item) => item.key), accountSignerKey] + : arr.map((item) => item.key) + const uniqueValues = new Set(extendedArr) + + return uniqueValues.size === extendedArr.length + }, + { + message: "You cannot use the same key twice", + }, + ), + confirmations: confirmationsSchema, + }) + // We increment by 1 to include the owner + .refine((data) => data.confirmations <= data.signerKeys.length + 1, { + message: "Confirmations should be less than or equal to signer keys", + path: ["confirmations"], + }) + +export type FieldValuesCreateMultisigForm = z.infer< + ReturnType +> + +export const useCreateMultisigForm = (accountSignerKey?: string) => { + return useForm({ + resolver: zodResolver(getFormSchema(accountSignerKey)), + }) +} diff --git a/packages/extension/src/ui/features/multisig/hooks/useCreatePendingMultisig.ts b/packages/extension/src/ui/features/multisig/hooks/useCreatePendingMultisig.ts new file mode 100644 index 000000000..755d51b43 --- /dev/null +++ b/packages/extension/src/ui/features/multisig/hooks/useCreatePendingMultisig.ts @@ -0,0 +1,19 @@ +import { useCallback, useState } from "react" + +import { createNewPendingMultisig } from "../../../services/backgroundMultisigs" + +export function useCreatePendingMultisig() { + const [isError, setIsError] = useState(false) + + const createPendingMultisig = useCallback(async (networkId: string) => { + const result = await createNewPendingMultisig(networkId) + + if (result === "error") { + setIsError(true) + } else { + return result + } + }, []) + + return { createPendingMultisig, isError } +} diff --git a/packages/extension/src/ui/features/multisig/hooks/useIsMultisigDeploying.ts b/packages/extension/src/ui/features/multisig/hooks/useIsMultisigDeploying.ts new file mode 100644 index 000000000..c8094636a --- /dev/null +++ b/packages/extension/src/ui/features/multisig/hooks/useIsMultisigDeploying.ts @@ -0,0 +1,21 @@ +/** + * Checks if there is a pending 'DEPLOY_ACCOUNT' transaction for the provided multisig account + * @param account - the account to check + * @returns boolean + */ + +import { useMemo } from "react" + +import { useDeployAccountTransactions } from "../../accounts/accountTransactions.state" +import { Multisig } from "../Multisig" + +export const useIsMultisigDeploying = (multisig?: Multisig) => { + const { pendingTransactions } = useDeployAccountTransactions(multisig) + return useMemo(() => { + if (!multisig) { + return false + } + + return pendingTransactions.length > 0 + }, [multisig, pendingTransactions.length]) +} diff --git a/packages/extension/src/ui/features/multisig/hooks/useIsSignerInMultisig.ts b/packages/extension/src/ui/features/multisig/hooks/useIsSignerInMultisig.ts new file mode 100644 index 000000000..13e6e946a --- /dev/null +++ b/packages/extension/src/ui/features/multisig/hooks/useIsSignerInMultisig.ts @@ -0,0 +1,20 @@ +import { useMemo } from "react" + +import { Multisig } from "../Multisig" + +/** + * This hook checks if the current signer is in the list of signers of a multisig account + * It is useful to verify if the current signer is part of the multisig account + * or it has been removed + * @param multisig A Multisig Account + * @returns boolean + */ + +export function useIsSignerInMultisig(multisig?: Multisig) { + return useMemo(() => { + if (!multisig) { + return false + } + return multisig.signers.includes(multisig.publicKey) + }, [multisig]) +} diff --git a/packages/extension/src/ui/features/multisig/hooks/useMultisigDataforAccount.ts b/packages/extension/src/ui/features/multisig/hooks/useMultisigDataforAccount.ts new file mode 100644 index 000000000..af7676065 --- /dev/null +++ b/packages/extension/src/ui/features/multisig/hooks/useMultisigDataforAccount.ts @@ -0,0 +1,58 @@ +import { useCallback } from "react" + +import { ARGENT_MULTISIG_ENABLED } from "../../../../shared/api/constants" +import { fetchMultisigDataForSigner } from "../../../../shared/multisig/multisig.service" +import { + BaseMultisigWalletAccount, + BaseWalletAccount, +} from "../../../../shared/wallet.model" +import { getAccountIdentifier } from "../../../../shared/wallet.service" +import { useConditionallyEnabledSWR } from "../../../services/swr" +import { + isZeroMultisigAccount, + useMultisigWalletAccount, +} from "../multisig.state" + +export function useMultisigDataForAccount(account: BaseWalletAccount) { + const multisigAccount = useMultisigWalletAccount(account) + + const multisigDataForSignerFetcher = useCallback(async () => { + if (!multisigAccount) { + return + } + + const data = await fetchMultisigDataForSigner({ + signer: multisigAccount.publicKey, + network: multisigAccount.network, + }) + + if (!data || !data.content || data.content.length === 0) { + if (!isZeroMultisigAccount(multisigAccount)) { + return multisigAccount + } + return + } + + return { + signers: data.content[0].signers, + threshold: data.content[0].threshold, + address: data.content[0].address, + creator: data.content[0].creator, + networkId: account.networkId, + publicKey: multisigAccount.publicKey, + } + }, [account.networkId, multisigAccount]) + + return useConditionallyEnabledSWR( + Boolean(ARGENT_MULTISIG_ENABLED), + [ + "multisigDataForSigner", + multisigAccount && getAccountIdentifier(multisigAccount), + multisigAccount?.publicKey, + ], + multisigDataForSignerFetcher, + { + refreshInterval: (latest) => (!latest ? 10e3 : 30e3), // if no data, refresh every 10s, otherwise every 30s + }, + ) +} diff --git a/packages/extension/src/ui/features/multisig/hooks/useUpdateThreshold.ts b/packages/extension/src/ui/features/multisig/hooks/useUpdateThreshold.ts new file mode 100644 index 000000000..703cc469c --- /dev/null +++ b/packages/extension/src/ui/features/multisig/hooks/useUpdateThreshold.ts @@ -0,0 +1,30 @@ +import { zodResolver } from "@hookform/resolvers/zod" +import { useForm } from "react-hook-form" +import { z } from "zod" + +import { confirmationsSchema } from "./useCreateMultisigForm" + +const getFormSchema = (signerKeysLength?: number) => + z + .object({ + confirmations: confirmationsSchema, + }) + .refine( + (data) => { + if (signerKeysLength) { + return data.confirmations <= signerKeysLength + 1 + } + }, + { + message: "Confirmations should be less than or equal to signer keys", + path: ["confirmations"], + }, + ) + +export type FieldValuesThresholdForm = z.infer> + +export const useUpdateThresholdForm = (signerKeysLength?: number) => { + return useForm({ + resolver: zodResolver(getFormSchema(signerKeysLength)), + }) +} diff --git a/packages/extension/src/ui/features/multisig/multisig.mock.ts b/packages/extension/src/ui/features/multisig/multisig.mock.ts new file mode 100644 index 000000000..e09a35ff0 --- /dev/null +++ b/packages/extension/src/ui/features/multisig/multisig.mock.ts @@ -0,0 +1,85 @@ +import { Abi, Contract } from "starknet" + +import ArgentCompiledContractAbi from "../../../abis/ArgentAccount.json" +import ProxyCompiledContractAbi from "../../../abis/Proxy.json" +import { Network, getProvider } from "../../../shared/network" +import { + ArgentAccountType, + WalletAccountSigner, +} from "../../../shared/wallet.model" +import { Multisig } from "./Multisig" + +const defaultNetwork: Network = { + id: "localhost", + name: "localhostNetwork", + chainId: "1", + baseUrl: "baseUrl", + status: "unknown", +} +const defaultSigner: WalletAccountSigner = { + type: "local_secret", + derivationPath: "derivationPath", +} +const defaultMultisigType: ArgentAccountType = "multisig" +const defaultFn = () => undefined +const defaultPromise = () => Promise.resolve("") +const defaultNeedsDeploy = false +const defaultAddress = "0x0" +const defaultHidden = false +const defaultName = "Multisig 1" + +const defaultMultisig: Multisig = { + name: defaultName, + address: defaultAddress, + network: defaultNetwork, + networkId: defaultNetwork.id, + signer: defaultSigner, + type: defaultMultisigType, + guardian: undefined, + provider: getProvider(defaultNetwork), + hidden: defaultHidden, + needsDeploy: defaultNeedsDeploy, + getDeployTransactionStorageKey: () => "key", + updateDeployTx: defaultFn, + completeDeployTx: defaultFn, + contract: new Contract( + ArgentCompiledContractAbi as Abi, + defaultAddress, + getProvider(defaultNetwork), + ), + proxyContract: new Contract( + ProxyCompiledContractAbi as Abi, + defaultAddress, + getProvider(defaultNetwork), + ), + getCurrentImplementation: defaultPromise, + toWalletAccount: () => ({ + name: defaultName, + networkId: defaultNetwork.id, + address: defaultAddress, + network: defaultNetwork, + signer: defaultSigner, + type: defaultMultisigType, + needsDeploy: defaultNeedsDeploy, + }), + toBaseWalletAccount: () => ({ + networkId: defaultNetwork.id, + address: defaultAddress, + }), + signers: ["0x0", "0x1"], + threshold: 2, + toBaseMultisigAccount: () => ({ + address: defaultAddress, + signers: ["0x0", "0x1"], + threshold: 2, + networkId: defaultNetwork.id, + publicKey: "0x0", + }), + publicKey: "0x0", + isZeroMultisig: () => false, +} + +export const getMockMultisig = (overrides: Partial) => ({ + ...defaultMultisig, + ...overrides, +}) diff --git a/packages/extension/src/ui/features/multisig/multisig.state.ts b/packages/extension/src/ui/features/multisig/multisig.state.ts new file mode 100644 index 000000000..fe103ee46 --- /dev/null +++ b/packages/extension/src/ui/features/multisig/multisig.state.ts @@ -0,0 +1,180 @@ +import { useMemo } from "react" + +import { + multisigBaseWalletStore, + pendingMultisigStore, +} from "../../../shared/multisig/store" +import { + BasePendingMultisig, + PendingMultisig, +} from "../../../shared/multisig/types" +import { + withHiddenPendingMultisig, + withoutHiddenPendingMultisig, +} from "../../../shared/multisig/utils/selectors" +import { useArrayStorage } from "../../../shared/storage/hooks" +import { + BaseMultisigWalletAccount, + BaseWalletAccount, + MultisigWalletAccount, +} from "../../../shared/wallet.model" +import { accountsEqual } from "../../../shared/wallet.service" +import { allAccountsView } from "../../views/account" +import { useView } from "../../views/implementation/react" +import { useCurrentNetwork } from "../networks/hooks/useCurrentNetwork" +import { Multisig } from "./Multisig" + +export const mapMultisigWalletAccountsToMultisig = ( + walletAccounts: MultisigWalletAccount[], +): Multisig[] => { + return walletAccounts.map( + (walletAccount) => + new Multisig({ + name: walletAccount.name, + address: walletAccount.address, + network: walletAccount.network, + signer: walletAccount.signer, + hidden: walletAccount.hidden, + type: walletAccount.type, + guardian: walletAccount.guardian, + escape: walletAccount.escape, + needsDeploy: walletAccount.needsDeploy, + signers: walletAccount.signers, + threshold: walletAccount.threshold, + creator: walletAccount.creator, + publicKey: walletAccount.publicKey, + }), + ) +} + +export function useBaseMultisigAccounts() { + return useArrayStorage(multisigBaseWalletStore) +} + +export function useMultisigAccounts() { + const accounts = useView(allAccountsView) + const baseMultisigAccounts = useBaseMultisigAccounts() + return useMemo(() => { + return baseMultisigAccounts + .map((baseMultisigAccount) => { + const walletAccount = accounts.find((walletAccount) => + accountsEqual(walletAccount, baseMultisigAccount), + ) + + if (!walletAccount) { + return undefined + } + + return { + ...walletAccount, + ...baseMultisigAccount, + type: "multisig", + } + }) + .filter((account): account is MultisigWalletAccount => !!account) + }, [accounts, baseMultisigAccounts]) +} + +export function useMultisigWalletAccount(base?: BaseWalletAccount) { + const multisigAccounts = useMultisigAccounts() + + return useMemo(() => { + if (!base) { + return + } + return multisigAccounts.find((multisigAccount) => + accountsEqual(multisigAccount, base), + ) + }, [base, multisigAccounts]) +} + +export function useMultisig(base?: BaseWalletAccount) { + const multisigWalletAccount = useMultisigWalletAccount(base) + + return useMemo(() => { + if (!multisigWalletAccount) { + return + } + + return new Multisig({ + name: multisigWalletAccount.name, + address: multisigWalletAccount.address, + network: multisigWalletAccount.network, + signer: multisigWalletAccount.signer, + hidden: multisigWalletAccount.hidden, + type: multisigWalletAccount.type, + guardian: multisigWalletAccount.guardian, + escape: multisigWalletAccount.escape, + needsDeploy: multisigWalletAccount.needsDeploy, + signers: multisigWalletAccount.signers, + threshold: multisigWalletAccount.threshold, + creator: multisigWalletAccount.creator, + publicKey: multisigWalletAccount.publicKey, + }) + }, [multisigWalletAccount]) +} + +export function isZeroMultisigAccount(account: BaseMultisigWalletAccount) { + return account.signers.length === 0 && account.threshold === 0 +} + +export function usePendingMultisigs({ + showHidden = false, + allNetworks = false, +} = {}) { + const network = useCurrentNetwork() + const pendingMultisigs = useArrayStorage(pendingMultisigStore) + + return useMemo( + () => + pendingMultisigs + .filter((pendingMultisigs) => { + /** omit if custom network no longer exists */ + return pendingMultisigs.networkId !== undefined + }) + .filter( + allNetworks + ? () => true + : (pendingMultisig) => pendingMultisig.networkId === network.id, + ) + .filter( + showHidden ? withHiddenPendingMultisig : withoutHiddenPendingMultisig, + ), + [pendingMultisigs, allNetworks, network.id, showHidden], + ) +} + +export const usePendingMultisigsOnNetwork = ({ + networkId, + showHidden = false, +}: { + networkId: string + showHidden: boolean +}) => { + const accounts = useArrayStorage(pendingMultisigStore) + + return useMemo( + () => + accounts + .filter((pendingMultisig) => pendingMultisig.networkId === networkId) + .filter( + showHidden ? withHiddenPendingMultisig : withoutHiddenPendingMultisig, + ), + [accounts, networkId, showHidden], + ) +} + +export function usePendingMultisig(base?: BasePendingMultisig) { + const pendingMultisigs = usePendingMultisigs() + + return useMemo(() => { + if (!base) { + return + } + return pendingMultisigs.find( + (pendingMultisig) => pendingMultisig.publicKey === base.publicKey, + ) + }, [base, pendingMultisigs]) +} + +export const isHiddenPendingMultisig = (pm: PendingMultisig) => !!pm.hidden diff --git a/packages/extension/src/ui/features/multisig/multisigTransactions.state.ts b/packages/extension/src/ui/features/multisig/multisigTransactions.state.ts new file mode 100644 index 000000000..fb82cf278 --- /dev/null +++ b/packages/extension/src/ui/features/multisig/multisigTransactions.state.ts @@ -0,0 +1,60 @@ +import { useMemo } from "react" + +import { SelectorFn } from "./../../../shared/storage/types" +import { + MultisigPendingTransaction, + byAccountSelector, + multisigPendingTransactionsStore, +} from "../../../shared/multisig/pendingTransactionsStore" +import { useArrayStorage } from "../../../shared/storage/hooks" +import { BaseWalletAccount } from "../../../shared/wallet.model" +import { useMultisig } from "./multisig.state" + +type UseMultisigAccountPendingTransactions = ( + account?: BaseWalletAccount, +) => MultisigPendingTransaction[] + +export const useMultisigPendingTransactions = ( + selector?: SelectorFn, + sorted = true, +) => { + const transactions = useArrayStorage( + multisigPendingTransactionsStore, + selector, + ) + + return useMemo( + () => + sorted + ? transactions.sort((a, b) => b.timestamp - a.timestamp) + : transactions, + [transactions, sorted], + ) +} + +export const useMultisigPendingTransactionsByAccount: UseMultisigAccountPendingTransactions = + (account) => { + return useMultisigPendingTransactions(byAccountSelector(account)) + } + +export const useMultisigPendingTransaction = (requestId?: string) => { + const [transaction] = useMultisigPendingTransactions( + (transaction) => transaction.requestId === requestId, + ) + + return useMemo( + () => (requestId ? transaction : undefined), + [requestId, transaction], + ) +} + +export const useMultisigPendingTransactionsAwaitingConfirmation = ( + account?: BaseWalletAccount, +) => { + const multisig = useMultisig(account) + + return useMultisigPendingTransactionsByAccount(account).filter( + (transaction) => + multisig && transaction.nonApprovedSigners.includes(multisig.publicKey), + ) +} diff --git a/packages/extension/src/ui/features/networks/NetworkSwitcher.tsx b/packages/extension/src/ui/features/networks/NetworkSwitcher.tsx deleted file mode 100644 index 64f15fb2e..000000000 --- a/packages/extension/src/ui/features/networks/NetworkSwitcher.tsx +++ /dev/null @@ -1,128 +0,0 @@ -import { B3, L2 } from "@argent/ui" -import { - Button, - Flex, - Menu, - MenuButton, - MenuItem, - MenuList, -} from "@chakra-ui/react" -import { FC, useCallback, useEffect } from "react" -import { useLocation, useNavigate } from "react-router-dom" - -import { NetworkStatus } from "../../../shared/network" -import { useAppState } from "../../app.state" -import { - StatusIndicator, - mapNetworkStatusToColor, -} from "../../components/StatusIndicator" -import { routes } from "../../routes" -import { autoSelectAccountOnNetwork } from "../accounts/switchAccount" -import { useNeedsToShowNetworkStatusWarning } from "./seenNetworkStatusWarning.state" -import { useNetwork, useNetworkStatuses, useNetworks } from "./useNetworks" - -const valuesToShowNetwortWarning: Array = ["degraded", "error"] - -interface NetworkSwitcherProps { - disabled?: boolean -} - -export const NetworkSwitcher: FC = ({ disabled }) => { - const navigate = useNavigate() - const location = useLocation() - const { switcherNetworkId } = useAppState() - const allNetworks = useNetworks() - const currentNetwork = useNetwork(switcherNetworkId) - const { networkStatuses } = useNetworkStatuses() - const [needsToShowNetworkStatusWarning] = useNeedsToShowNetworkStatusWarning() - const currentNetworkStatus = networkStatuses[currentNetwork.id] - - useEffect(() => { - if ( - currentNetworkStatus && - valuesToShowNetwortWarning.includes(currentNetworkStatus) && - needsToShowNetworkStatusWarning - ) { - navigate(routes.networkWarning(location.pathname)) - } - // just trigger on network status change - }, [currentNetworkStatus]) // eslint-disable-line react-hooks/exhaustive-deps - - const onChangeNetwork = useCallback(async (networkId: string) => { - await autoSelectAccountOnNetwork(networkId) - }, []) - - return ( - - - } - > - {currentNetwork.name} - - - {allNetworks.map(({ id, name, baseUrl }) => { - const isCurrent = id === currentNetwork.id - return ( - onChangeNetwork(id)} - data-testid={name} - sx={ - isCurrent - ? { - backgroundColor: "neutrals.600", - } - : {} - } - data-group - > - - - - {name} - - - {baseUrl} - - - - - - ) - })} - - - ) -} diff --git a/packages/extension/src/ui/features/networks/NetworkSwitcher/NetworkSwitcher.test.tsx b/packages/extension/src/ui/features/networks/NetworkSwitcher/NetworkSwitcher.test.tsx new file mode 100644 index 000000000..e235bfd18 --- /dev/null +++ b/packages/extension/src/ui/features/networks/NetworkSwitcher/NetworkSwitcher.test.tsx @@ -0,0 +1,128 @@ +import { Menu } from "@chakra-ui/react" +import { render, screen, within } from "@testing-library/react" +import userEvent from "@testing-library/user-event" + +import { Network, NetworkStatus } from "../../../../shared/network" +import { mapNetworkStatusToColor } from "../../../components/StatusIndicator" +import { NetworkSwitcherList } from "./NetworkSwitcherList" + +const mockNetworks = [ + { + id: "1", + name: "Mainnet", + baseUrl: "https://mainnet.infura.io", + chainId: "chainId", + status: "ok", + }, + { + id: "2", + name: "Rinkeby", + baseUrl: "https://rinkeby.infura.io", + chainId: "chainId", + status: "error", + }, + { + id: "3", + name: "Kovan", + baseUrl: "https://kovan.infura.io", + chainId: "chainId", + status: "degraded", + }, +] as Network[] + +const mockNetworkStatuses = { + "1": "ok", + "2": "degraded", + "3": "error", +} as Partial> + +const mockCurrentNetwork = mockNetworks[0] + +describe("NetworkSwitcherList", () => { + it("renders all the network options", () => { + const handleChangeNetwork = vi.fn() + render( + + + , + ) + + mockNetworks.forEach(({ name }) => { + expect(screen.getByTestId(name)).toBeInTheDocument() + }) + }) + + it("calls onChangeNetwork when a network is clicked", () => { + const handleChangeNetwork = vi.fn() + + render( + + + , + ) + + const networkToSelect = mockNetworks[1] + const networkMenuItemToSelect = screen.getByTestId(networkToSelect.name) + + userEvent.click(networkMenuItemToSelect) + + expect(handleChangeNetwork).toHaveBeenCalledWith(networkToSelect.id) + }) + + it("displays the network status indicator for each network", () => { + const handleChangeNetwork = vi.fn() + render( + + + , + ) + + mockNetworks.forEach(({ id }) => { + const networkStatus = mockNetworkStatuses[id] + const statusIndicator = screen.getByTestId( + `status-indicator-${mapNetworkStatusToColor(networkStatus)}`, + ) + + expect(statusIndicator).toBeInTheDocument() + if (networkStatus) { + expect(statusIndicator).toHaveStyle({ + backgroundColor: expect.stringContaining(networkStatus), + }) + } + }) + }) + + it("displays the network name and base URL", () => { + const onChangeNetwork = vi.fn() + const selectedNetwork = mockNetworks[1] + + render( + + + , + ) + + const menuItem = screen.getByTestId(selectedNetwork.name) + const nameElement = within(menuItem).getByText(selectedNetwork.name) + const baseUrlElement = within(menuItem).getByText(selectedNetwork.baseUrl) + + expect(nameElement).toBeInTheDocument() + expect(baseUrlElement).toBeInTheDocument() + }) +}) diff --git a/packages/extension/src/ui/features/networks/NetworkSwitcher/NetworkSwitcherButton.tsx b/packages/extension/src/ui/features/networks/NetworkSwitcher/NetworkSwitcherButton.tsx new file mode 100644 index 000000000..5ab847e8a --- /dev/null +++ b/packages/extension/src/ui/features/networks/NetworkSwitcher/NetworkSwitcherButton.tsx @@ -0,0 +1,38 @@ +import { Button, MenuButton } from "@chakra-ui/react" +import { FC } from "react" + +import { Network, NetworkStatus } from "../../../../shared/network" +import { + StatusIndicator, + mapNetworkStatusToColor, +} from "../../../components/StatusIndicator" + +interface NetworkSwitcherButtonProps { + disabled?: boolean + currentNetworkStatus: NetworkStatus + currentNetwork: Network +} + +export const NetworkSwitcherButton: FC = ({ + disabled, + currentNetworkStatus, + currentNetwork, +}) => { + return ( + + } + > + {currentNetwork.name} + + ) +} diff --git a/packages/extension/src/ui/features/networks/NetworkSwitcher/NetworkSwitcherContainer.tsx b/packages/extension/src/ui/features/networks/NetworkSwitcher/NetworkSwitcherContainer.tsx new file mode 100644 index 000000000..d82a194aa --- /dev/null +++ b/packages/extension/src/ui/features/networks/NetworkSwitcher/NetworkSwitcherContainer.tsx @@ -0,0 +1,61 @@ +import { Menu } from "@chakra-ui/react" +import { FC, useCallback, useEffect } from "react" +import { useLocation, useNavigate } from "react-router-dom" + +import { NetworkStatus } from "../../../../shared/network" +import { routes } from "../../../routes" +import { autoSelectAccountOnNetwork } from "../../accounts/switchAccount" +import { useCurrentNetwork } from "../hooks/useCurrentNetwork" +import { useNeedsToShowNetworkStatusWarning } from "../hooks/useNeedsToShowNetworkStatusWarning" +import { useNetworks } from "../hooks/useNetworks" +import { NetworkSwitcherButton } from "./NetworkSwitcherButton" +import { NetworkSwitcherList } from "./NetworkSwitcherList" + +const valuesToShowNetwortWarning: Array = ["degraded", "error"] + +interface NetworkSwitcherProps { + disabled?: boolean +} + +export const NetworkSwitcherContainer: FC = ({ + disabled, +}) => { + const navigate = useNavigate() + const location = useLocation() + const allNetworks = useNetworks() + const currentNetwork = useCurrentNetwork() + const [needsToShowNetworkStatusWarning] = useNeedsToShowNetworkStatusWarning() + const currentNetworkStatus = currentNetwork.status + + useEffect(() => { + if ( + currentNetworkStatus && + valuesToShowNetwortWarning.includes(currentNetworkStatus) && + needsToShowNetworkStatusWarning + ) { + navigate(routes.networkWarning(location.pathname)) + } + // just trigger on network status change + }, [currentNetworkStatus]) // eslint-disable-line react-hooks/exhaustive-deps + + const onChangeNetwork = useCallback(async (networkId: string) => { + await autoSelectAccountOnNetwork(networkId) + }, []) + + return ( + + {currentNetworkStatus && ( + + )} + + + ) +} diff --git a/packages/extension/src/ui/features/networks/NetworkSwitcher/NetworkSwitcherList.tsx b/packages/extension/src/ui/features/networks/NetworkSwitcher/NetworkSwitcherList.tsx new file mode 100644 index 000000000..34d4fe091 --- /dev/null +++ b/packages/extension/src/ui/features/networks/NetworkSwitcher/NetworkSwitcherList.tsx @@ -0,0 +1,77 @@ +import { B3, L2 } from "@argent/ui" +import { Flex, MenuItem, MenuList } from "@chakra-ui/react" +import { FC } from "react" + +import { Network } from "../../../../shared/network" +import { + StatusIndicator, + mapNetworkStatusToColor, +} from "../../../components/StatusIndicator" + +interface NetworkSwitcherListProps { + currentNetwork: Network + allNetworks: Network[] + onChangeNetwork: (id: string) => void +} + +export const NetworkSwitcherList: FC = ({ + currentNetwork, + allNetworks, + onChangeNetwork, +}) => { + return ( + + {allNetworks.map(({ id, name, baseUrl, status }) => { + const isCurrent = id === currentNetwork.id + return ( + onChangeNetwork(id)} + data-testid={name} + sx={ + isCurrent + ? { + backgroundColor: "neutrals.600", + } + : {} + } + data-group + > + + + + {name} + + + {baseUrl} + + + + + + ) + })} + + ) +} diff --git a/packages/extension/src/ui/features/networks/NetworkWarningScreen/NetworkWarningScreen.test.tsx b/packages/extension/src/ui/features/networks/NetworkWarningScreen/NetworkWarningScreen.test.tsx new file mode 100644 index 000000000..8830a721e --- /dev/null +++ b/packages/extension/src/ui/features/networks/NetworkWarningScreen/NetworkWarningScreen.test.tsx @@ -0,0 +1,29 @@ +import { render, screen } from "@testing-library/react" +import userEvent from "@testing-library/user-event" + +import { NetworkWarningScreen } from "./NetworkWarningScreen" + +describe("NetworkWarningScreen", () => { + const onClickMock = vi.fn() + + it("renders the warning message and button", () => { + render() + const titleElement = screen.getByText(/Network issues/i) + expect(titleElement).toBeInTheDocument() + + const descriptionElement = screen.getByText( + /StarkNet is in Alpha and is experiencing degraded network performance./i, + ) + expect(descriptionElement).toBeInTheDocument() + + const buttonElement = screen.getByRole("button", { name: /I understand/i }) + expect(buttonElement).toBeInTheDocument() + }) + + it("calls the onClick function when the button is clicked", () => { + render() + const buttonElement = screen.getByRole("button", { name: /I understand/i }) + userEvent.click(buttonElement) + expect(onClickMock).toHaveBeenCalled() + }) +}) diff --git a/packages/extension/src/ui/features/networks/NetworkWarningScreen.tsx b/packages/extension/src/ui/features/networks/NetworkWarningScreen/NetworkWarningScreen.tsx similarity index 67% rename from packages/extension/src/ui/features/networks/NetworkWarningScreen.tsx rename to packages/extension/src/ui/features/networks/NetworkWarningScreen/NetworkWarningScreen.tsx index 939b0403f..ddf54f928 100644 --- a/packages/extension/src/ui/features/networks/NetworkWarningScreen.tsx +++ b/packages/extension/src/ui/features/networks/NetworkWarningScreen/NetworkWarningScreen.tsx @@ -1,19 +1,15 @@ import { H3, P3, icons } from "@argent/ui" import { Button, Center, Circle, Text } from "@chakra-ui/react" import { FC } from "react" -import { useNavigate } from "react-router-dom" - -import { routes, useReturnTo } from "../../routes" -import { useNeedsToShowNetworkStatusWarning } from "./seenNetworkStatusWarning.state" const { NetworkIcon } = icons -export const NetworkWarningScreen: FC = () => { - const navigate = useNavigate() - const returnTo = useReturnTo() - const [, updateNeedsToShowNetworkStatusWarning] = - useNeedsToShowNetworkStatusWarning() - +type NetworkWarningScreenProps = { + onClick: () => void +} +export const NetworkWarningScreen: FC = ({ + onClick, +}) => { return (
@@ -43,10 +39,7 @@ export const NetworkWarningScreen: FC = () => { mt={6} width={["100%", "initial"]} colorScheme="primary" - onClick={() => { - updateNeedsToShowNetworkStatusWarning() - navigate(returnTo ? returnTo : routes.accounts()) - }} + onClick={onClick} > I understand diff --git a/packages/extension/src/ui/features/networks/NetworkWarningScreen/NetworkWarningScreenContainer.tsx b/packages/extension/src/ui/features/networks/NetworkWarningScreen/NetworkWarningScreenContainer.tsx new file mode 100644 index 000000000..7e382604b --- /dev/null +++ b/packages/extension/src/ui/features/networks/NetworkWarningScreen/NetworkWarningScreenContainer.tsx @@ -0,0 +1,18 @@ +import { FC } from "react" +import { useNavigate } from "react-router-dom" + +import { routes, useReturnTo } from "../../../routes" +import { useNeedsToShowNetworkStatusWarning } from "../hooks/useNeedsToShowNetworkStatusWarning" +import { NetworkWarningScreen } from "./NetworkWarningScreen" + +export const NetworkWarningScreenContainer: FC = () => { + const navigate = useNavigate() + const returnTo = useReturnTo() + const [, updateNeedsToShowNetworkStatusWarning] = + useNeedsToShowNetworkStatusWarning() + const onClick = () => { + updateNeedsToShowNetworkStatusWarning() + navigate(returnTo ? returnTo : routes.accounts()) + } + return +} diff --git a/packages/extension/src/ui/features/networks/hooks/useCurrentNetwork.ts b/packages/extension/src/ui/features/networks/hooks/useCurrentNetwork.ts new file mode 100644 index 000000000..02d0af9c7 --- /dev/null +++ b/packages/extension/src/ui/features/networks/hooks/useCurrentNetwork.ts @@ -0,0 +1,7 @@ +import { useAppState } from "../../../app.state" +import { useNetwork } from "./useNetwork" + +export const useCurrentNetwork = () => { + const { switcherNetworkId } = useAppState() + return useNetwork(switcherNetworkId) +} diff --git a/packages/extension/src/ui/features/networks/hooks/useIsMainnet.ts b/packages/extension/src/ui/features/networks/hooks/useIsMainnet.ts new file mode 100644 index 000000000..8fc92f310 --- /dev/null +++ b/packages/extension/src/ui/features/networks/hooks/useIsMainnet.ts @@ -0,0 +1,6 @@ +import { useAppState } from "../../../app.state" + +export const useIsMainnet = () => { + const { switcherNetworkId } = useAppState() + return switcherNetworkId === "mainnet-alpha" +} diff --git a/packages/extension/src/ui/features/networks/seenNetworkStatusWarning.state.ts b/packages/extension/src/ui/features/networks/hooks/useNeedsToShowNetworkStatusWarning.ts similarity index 85% rename from packages/extension/src/ui/features/networks/seenNetworkStatusWarning.state.ts rename to packages/extension/src/ui/features/networks/hooks/useNeedsToShowNetworkStatusWarning.ts index e66d7851a..be9527020 100644 --- a/packages/extension/src/ui/features/networks/seenNetworkStatusWarning.state.ts +++ b/packages/extension/src/ui/features/networks/hooks/useNeedsToShowNetworkStatusWarning.ts @@ -1,4 +1,4 @@ -import create from "zustand" +import { create } from "zustand" import { persist } from "zustand/middleware" interface SeenNetworkStatusState { @@ -6,7 +6,7 @@ interface SeenNetworkStatusState { updateLastSeen: () => void } -const useSeenNetworkStatus = create( +const useSeenNetworkStatus = create()( persist( (set, _get) => ({ lastSeen: 0, diff --git a/packages/extension/src/ui/features/networks/hooks/useNetwork.ts b/packages/extension/src/ui/features/networks/hooks/useNetwork.ts new file mode 100644 index 000000000..3ac1a69f3 --- /dev/null +++ b/packages/extension/src/ui/features/networks/hooks/useNetwork.ts @@ -0,0 +1,13 @@ +import { useMemo } from "react" + +import { defaultNetwork } from "../../../../shared/network" +import { useNetworks } from "./useNetworks" + +export const useNetwork = (networkId: string) => { + const networks = useNetworks() + return useMemo( + () => + networks.find((network) => network.id === networkId) || defaultNetwork, + [networks, networkId], + ) +} diff --git a/packages/extension/src/ui/features/networks/hooks/useNetworks.ts b/packages/extension/src/ui/features/networks/hooks/useNetworks.ts new file mode 100644 index 000000000..4e6d310c3 --- /dev/null +++ b/packages/extension/src/ui/features/networks/hooks/useNetworks.ts @@ -0,0 +1,8 @@ +import { networksView } from "../../../../shared/network/view" +import { useView } from "../../../views/implementation/react" + +export const useNetworks = () => { + const networks = useView(networksView) + + return networks +} diff --git a/packages/extension/src/ui/features/networks/showNetworkUpgrade.ts b/packages/extension/src/ui/features/networks/hooks/useShouldShowNetworkUpgradeMessage.ts similarity index 92% rename from packages/extension/src/ui/features/networks/showNetworkUpgrade.ts rename to packages/extension/src/ui/features/networks/hooks/useShouldShowNetworkUpgradeMessage.ts index 1bc61dd0c..c58ab1e6b 100644 --- a/packages/extension/src/ui/features/networks/showNetworkUpgrade.ts +++ b/packages/extension/src/ui/features/networks/hooks/useShouldShowNetworkUpgradeMessage.ts @@ -1,14 +1,14 @@ -import create from "zustand" +import { create } from "zustand" import { persist } from "zustand/middleware" -import { useCheckV4UpgradeAvailable } from "../accounts/upgrade.service" +import { useCheckV4UpgradeAvailable } from "../../accounts/upgrade.service" interface ShowNetworkUpgradeMessage { lastShown: number updateLastShown: () => void } -const useShowNetworkUpgradeMessage = create( +const useShowNetworkUpgradeMessage = create()( persist( (set, _get) => ({ lastShown: 0, diff --git a/packages/extension/src/ui/features/networks/useNetworks.ts b/packages/extension/src/ui/features/networks/useNetworks.ts deleted file mode 100644 index 341d83c6c..000000000 --- a/packages/extension/src/ui/features/networks/useNetworks.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { useMemo } from "react" -import useSWR from "swr" - -import { - customNetworksStore, - defaultNetwork, - extendByDefaultReadonlyNetworks, -} from "../../../shared/network" -import { useArrayStorage } from "../../../shared/storage/hooks" -import { useAppState } from "./../../app.state" -import { getNetworkStatuses } from "../../services/backgroundNetworks" -import { SWRConfigCommon } from "../../services/swr" - -export const useNetworkStatuses = (config?: SWRConfigCommon) => { - const { data: networkStatuses = {}, ...rest } = useSWR( - "networkStatuses-all", - () => getNetworkStatuses(), - { - refreshInterval: 15e3 /* 15 seconds */, // gets cached in background anyways, so we can refresh it as fast as we want/makes sense - ...config, - }, - ) - return { - networkStatuses, - ...rest, - } -} - -export const useIsMainnet = () => { - const { switcherNetworkId } = useAppState() - return switcherNetworkId === "mainnet-alpha" -} - -export const useNetworks = () => { - const customNetworks = useArrayStorage(customNetworksStore) - return useMemo( - () => extendByDefaultReadonlyNetworks(customNetworks), - [customNetworks], - ) -} - -export const useCustomNetworks = () => { - const customNetworks = useArrayStorage(customNetworksStore) - return customNetworks -} - -export const useNetwork = (networkId: string) => { - const networks = useNetworks() - return useMemo( - () => - networks.find((network) => network.id === networkId) || defaultNetwork, - [networks, networkId], - ) -} - -export const useCurrentNetwork = () => { - const { switcherNetworkId } = useAppState() - return useNetwork(switcherNetworkId) -} diff --git a/packages/extension/src/ui/features/onboarding/MigrationDisclaimerScreen.tsx b/packages/extension/src/ui/features/onboarding/MigrationDisclaimerScreen.tsx deleted file mode 100644 index 2c99555ff..000000000 --- a/packages/extension/src/ui/features/onboarding/MigrationDisclaimerScreen.tsx +++ /dev/null @@ -1,81 +0,0 @@ -// FIXME: remove when depricated accounts do not longer work -import { FC } from "react" -import { useNavigate } from "react-router-dom" -import styled from "styled-components" - -import { useAppState } from "../../app.state" -import { Button } from "../../components/Button" -import { OpenInNewIcon } from "../../components/Icons/MuiIcons" -import { routes } from "../../routes" -import { selectAccount } from "../../services/backgroundAccounts" -import { H2, P } from "../../theme/Typography" -import { createAccount } from "../accounts/accounts.service" -import { recover } from "../recovery/recovery.service" - -const Container = styled.div` - padding: 88px 40px 24px 40px; - display: flex; - flex-direction: column; - flex: 1; - ${P} { - font-weight: 600; - margin-top: 15px; - } - - a { - color: ${({ theme }) => theme.red3}; - text-decoration: none; - } -` - -const ButtonSpacer = styled.div` - display: flex; - flex: 1; -` - -export const MigrationDisclaimerScreen: FC = () => { - const navigate = useNavigate() - const { switcherNetworkId } = useAppState() - - const handleAddAccount = async () => { - useAppState.setState({ isLoading: true }) - try { - const newAccount = await createAccount(switcherNetworkId) - selectAccount(newAccount) - navigate(await recover()) - } catch (error: any) { - useAppState.setState({ error: `${error}` }) - navigate(routes.error()) - } finally { - useAppState.setState({ isLoading: false }) - } - } - - return ( - -

Please migrate your funds

-

- StarkNet is in Alpha and its testnet has made breaking changes. Mainnet - will follow soon. -

-

- Please create a new account and send all your assets from your old - account(s) to this new one. You may need to use a dapp to do this. -

-

- Old accounts will not be recoverable with your backup or seed phrase. -

-

- - Read more about this change - -

- - -
- ) -} diff --git a/packages/extension/src/ui/features/onboarding/OnboardingDisclaimerScreen.test.tsx b/packages/extension/src/ui/features/onboarding/OnboardingDisclaimerScreen.test.tsx new file mode 100644 index 000000000..20e96ac74 --- /dev/null +++ b/packages/extension/src/ui/features/onboarding/OnboardingDisclaimerScreen.test.tsx @@ -0,0 +1,44 @@ +import { fireEvent, render, screen } from "@testing-library/react" +import { describe, expect, it } from "vitest" + +import { OnboardingDisclaimerScreen } from "./OnboardingDisclaimerScreen" + +describe("OnboardingDisclaimerScreen", () => { + it("onContinue does not work until boxes are checked", async () => { + const onContinue = vi.fn() + const onPrivacy = vi.fn() + + render( + , + ) + + expect(screen.getByText(/^Continue/)).toBeDisabled() + + fireEvent.click(screen.getByText(/introduce changes/)) + fireEvent.click(screen.getByText(/experience performance/)) + + expect(screen.getByText(/^Continue/)).not.toBeDisabled() + fireEvent.click(screen.getByText(/^Continue/)) + + expect(onContinue).toHaveBeenCalled() + }) + + it("calls onPrivacy when appropriate button is clicked", () => { + const onContinue = vi.fn() + const onPrivacy = vi.fn() + + render( + , + ) + + fireEvent.click(screen.getByText(/^Privacy/)) + + expect(onPrivacy).toHaveBeenCalled() + }) +}) diff --git a/packages/extension/src/ui/features/onboarding/OnboardingDisclaimerScreen.tsx b/packages/extension/src/ui/features/onboarding/OnboardingDisclaimerScreen.tsx index 91998f48c..3c0166f5b 100644 --- a/packages/extension/src/ui/features/onboarding/OnboardingDisclaimerScreen.tsx +++ b/packages/extension/src/ui/features/onboarding/OnboardingDisclaimerScreen.tsx @@ -1,121 +1,58 @@ -import { - Checkbox, - FormControl, - FormControlLabel, - FormGroup, -} from "@mui/material" -import { ChangeEventHandler, FC, useState } from "react" -import { useNavigate } from "react-router-dom" -import styled from "styled-components" +import { B3 } from "@argent/ui" +import { Box, useCheckboxGroup } from "@chakra-ui/react" +import { FC, MouseEventHandler } from "react" -import { - CheckCircleIcon, - RadioButtonUncheckedIcon, -} from "../../components/Icons/MuiIcons" -import { PrivacyStatementLink } from "../../components/PrivacyStatementLink" -import { routes } from "../../routes" -import { - usePageTracking, - useTimeSpentWithSuccessTracking, -} from "../../services/analytics" -import { P3 } from "../../theme/Typography" import { OnboardingButton } from "./ui/OnboardingButton" +import { OnboardingCheckbox } from "./ui/OnboardingCheckbox" import { OnboardingScreen } from "./ui/OnboardingScreen" -const StyledPrivacyStatementLink = styled(PrivacyStatementLink)` - display: flex; - text-align: right; - margin-left: auto; - margin-top: 8px; -` - -const StyledFormControlLabel = styled(FormControlLabel)` - margin: 0 0 8px 0; - border: 1px solid ${({ theme }) => theme.neutrals600}; - border-radius: 8px; - padding: 24px 20px 24px 12px; - gap: 8px; - &:active { - transform: scale(0.975); - } - transition: transform 100ms ease-in-out; -` +interface OnboardingDisclaimerScreenProps { + onBack?: MouseEventHandler + onContinue: MouseEventHandler + onPrivacy: MouseEventHandler +} -export const OnboardingDisclaimerScreen: FC = () => { - usePageTracking("disclaimer") - const { trackSuccess } = useTimeSpentWithSuccessTracking( - "onboardingStepFinished", - { stepId: "disclaimer" }, - ) - const navigate = useNavigate() - const [conditions, setConditions] = useState({ - lossOfFunds: false, - alphaVersion: false, +export const OnboardingDisclaimerScreen: FC< + OnboardingDisclaimerScreenProps +> = ({ onContinue, onPrivacy, onBack }) => { + const { value, getCheckboxProps } = useCheckboxGroup({ + defaultValue: [], }) - const handleChange: ChangeEventHandler = ({ target }) => - setConditions({ ...conditions, [target.name]: target.checked }) - return ( - - - } - checkedIcon={} - color="success" - /> - } - label={ - - I understand that StarkNet may introduce changes that make my - existing account unusable and force to create new ones. - - } - /> - } - checkedIcon={} - color="success" - /> - } - label={ - - I understand that StarkNet may experience performance issues and - my transactions may fail for various reasons. - - } - /> - - - -
- { - trackSuccess() - navigate(routes.onboardingPassword()) - }} - > + + I understand that StarkNet may introduce changes that make my existing + account unusable and force to create new ones. + + + I understand that StarkNet may experience performance issues and my + transactions may fail for various reasons. + + + Privacy statement + + + Continue -
+
) } diff --git a/packages/extension/src/ui/features/onboarding/OnboardingDisclaimerScreenContainer.tsx b/packages/extension/src/ui/features/onboarding/OnboardingDisclaimerScreenContainer.tsx new file mode 100644 index 000000000..c484687b8 --- /dev/null +++ b/packages/extension/src/ui/features/onboarding/OnboardingDisclaimerScreenContainer.tsx @@ -0,0 +1,38 @@ +import { useNavigateBack } from "@argent/ui" +import { FC, useCallback } from "react" +import { useNavigate } from "react-router-dom" + +import { routes } from "../../routes" +import { + usePageTracking, + useTimeSpentWithSuccessTracking, +} from "../../services/analytics" +import { OnboardingDisclaimerScreen } from "./OnboardingDisclaimerScreen" + +export const OnboardingDisclaimerScreenContainer: FC = () => { + usePageTracking("disclaimer") + const { trackSuccess } = useTimeSpentWithSuccessTracking( + "onboardingStepFinished", + { stepId: "disclaimer" }, + ) + + const navigate = useNavigate() + const onBack = useNavigateBack() + + const onContinue = useCallback(() => { + trackSuccess() + navigate(routes.onboardingPassword()) + }, [navigate, trackSuccess]) + + const onPrivacy = useCallback(() => { + navigate(routes.onboardingPrivacyStatement()) + }, [navigate]) + + return ( + + ) +} diff --git a/packages/extension/src/ui/features/onboarding/OnboardingFinishScreen.test.tsx b/packages/extension/src/ui/features/onboarding/OnboardingFinishScreen.test.tsx new file mode 100644 index 000000000..a3d4f29aa --- /dev/null +++ b/packages/extension/src/ui/features/onboarding/OnboardingFinishScreen.test.tsx @@ -0,0 +1,78 @@ +import { render } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import { describe, expect, it, vi } from "vitest" + +import { + OnboardingFinishScreen, + OnboardingFinishScreenProps, +} from "./OnboardingFinishScreen" + +describe("OnboardingFinishScreen", () => { + const defaultProps: OnboardingFinishScreenProps = { + onFinish: vi.fn(), + } + + const renderComponent = ( + props: OnboardingFinishScreenProps = defaultProps, + ) => { + return render() + } + + it("renders the title, subtitle, and icon correctly", () => { + const screen = renderComponent() + + expect(screen.getByText("Your wallet is ready!")).toBeInTheDocument() + expect( + screen.getByText( + "Follow us for product updates or if you have any questions", + ), + ).toBeInTheDocument() + expect(screen.getByTestId("TickCircleIcon")).toBeInTheDocument() + }) + + it("renders the follow Twitter button and link", () => { + const screen = renderComponent() + + const twitterButton = screen.getByText("Follow Argent X on Twitter") + expect(twitterButton).toBeInTheDocument() + expect(twitterButton.closest("a")).toHaveAttribute( + "href", + "https://twitter.com/argenthq", + ) + expect(twitterButton.closest("a")).toHaveAttribute("target", "_blank") + }) + + it("renders the join Discord button and link", () => { + const screen = renderComponent() + + const discordButton = screen.getByText("Join the Argent X Discord") + expect(discordButton).toBeInTheDocument() + expect(discordButton.closest("a")).toHaveAttribute( + "href", + "https://discord.gg/T4PDFHxm6T", + ) + expect(discordButton.closest("a")).toHaveAttribute("target", "_blank") + }) + + it("renders the finish button and calls onFinishClick when clicked", () => { + const onFinish = vi.fn() + const screen = renderComponent({ + onFinish, + }) + + const finishButton = screen.getByText("Finish") + expect(finishButton).toBeInTheDocument() + + userEvent.click(finishButton) + expect(onFinish).toHaveBeenCalledTimes(1) + }) + + it.skip("renders the snackbar with pin extension message and icon", () => { + const screen = renderComponent() + + expect( + screen.getByText("Pin the Argent X extension for quick access"), + ).toBeInTheDocument() + expect(screen.getByTestId("extension-icon")).toBeInTheDocument() + }) +}) diff --git a/packages/extension/src/ui/features/onboarding/OnboardingFinishScreen.tsx b/packages/extension/src/ui/features/onboarding/OnboardingFinishScreen.tsx index 88eefdf21..cc801a4aa 100644 --- a/packages/extension/src/ui/features/onboarding/OnboardingFinishScreen.tsx +++ b/packages/extension/src/ui/features/onboarding/OnboardingFinishScreen.tsx @@ -1,147 +1,58 @@ -import { Twitter } from "@mui/icons-material" -import { Slide, SlideProps, Snackbar } from "@mui/material" -import { FC, useCallback } from "react" -import styled from "styled-components" +import { icons, logos } from "@argent/ui" +import { Circle, SimpleGrid } from "@chakra-ui/react" +import { FC, ReactEventHandler } from "react" -import { ArgentXLogo } from "../../components/Icons/ArgentXLogo" -import { DiscordIcon } from "../../components/Icons/DiscordIcon" -import { - CheckCircleOutlineRoundedIcon, - ExtensionIcon, - PushPinIcon, -} from "../../components/Icons/MuiIcons" -import Row from "../../components/Row" -import { useTimeSpentWithSuccessTracking } from "../../services/analytics" +import { useOnboardingToastMessage } from "./hooks/useOnboardingToastMessage" import { OnboardingButton } from "./ui/OnboardingButton" +import { OnboardingRectButton } from "./ui/OnboardingRectButton" import { OnboardingScreen } from "./ui/OnboardingScreen" -import { - DiscordRectButtonIcon, - RectButton, - TwitterRectButtonIcon, -} from "./ui/RectButton" -const StyledOnboardingButton = styled(OnboardingButton)` - margin-top: 32px; -` +const { TickCircleIcon } = icons +const { Twitter, Discord } = logos -const StyledSnackbar = styled(Snackbar)` - .MuiSnackbarContent-root { - border-radius: 8px; - margin: 0; - } -` - -const ArgentXButton = styled.div` - display: flex; - align-items: center; - border-radius: 8px; - background-color: #f0f0f0; - padding: 12px; - gap: 12px; - font-weight: 600; -` - -const SnackbarMessageContainer = styled.div` - display: flex; - flex-direction: column; - gap: 8px; - max-width: 260px; - font-size: 16px; -` - -const SnackbarIconContainer = styled.div` - margin-right: 12px; -` - -const StyledArgentXLogo = styled(ArgentXLogo)` - font-size: 20px; - color: ${({ theme }) => theme.primary}; -` - -const StyledPushPinIcon = styled(PushPinIcon)` - margin-left: auto; -` - -const SnackbarMessage: FC = () => { - return ( - - - - - - Pin the Argent X extension for quick access - - - - Argent X - - - - ) -} - -const StyledCheckCircleOutlineRoundedIcon = styled( - CheckCircleOutlineRoundedIcon, -)` - font-size: 77px; /** gives inner icon ~64px */ -` - -const TransitionLeft: FC = (props) => { - return +export interface OnboardingFinishScreenProps { + onFinish: ReactEventHandler } -export const OnboardingFinishScreen: FC = () => { - const { trackSuccess } = useTimeSpentWithSuccessTracking( - "onboardingStepFinished", - { stepId: "finish" }, - ) - const onFinishClick = useCallback(() => { - trackSuccess() - window.close() - }, [trackSuccess]) +export const OnboardingFinishScreen: FC = ({ + onFinish, +}) => { + useOnboardingToastMessage() return ( - <> - } - TransitionComponent={TransitionLeft} - /> - } - > - - - - - - Follow Argent X on Twitter - - - - - - Join the Argent X Discord - - - - Finish - - - + } + > + + + + + + Follow Argent X on Twitter + + + + + + Join the Argent X Discord + + + + Finish + + ) } diff --git a/packages/extension/src/ui/features/onboarding/OnboardingFinishScreenContainer.tsx b/packages/extension/src/ui/features/onboarding/OnboardingFinishScreenContainer.tsx new file mode 100644 index 000000000..30a6478a5 --- /dev/null +++ b/packages/extension/src/ui/features/onboarding/OnboardingFinishScreenContainer.tsx @@ -0,0 +1,17 @@ +import { FC, useCallback } from "react" + +import { useTimeSpentWithSuccessTracking } from "../../services/analytics" +import { OnboardingFinishScreen } from "./OnboardingFinishScreen" + +export const OnboardingFinishScreenContainer: FC = () => { + const { trackSuccess } = useTimeSpentWithSuccessTracking( + "onboardingStepFinished", + { stepId: "finish" }, + ) + const onFinish = useCallback(() => { + void trackSuccess() + window.close() + }, [trackSuccess]) + + return +} diff --git a/packages/extension/src/ui/features/onboarding/OnboardingPasswordScreen.test.tsx b/packages/extension/src/ui/features/onboarding/OnboardingPasswordScreen.test.tsx new file mode 100644 index 000000000..2f8b9749c --- /dev/null +++ b/packages/extension/src/ui/features/onboarding/OnboardingPasswordScreen.test.tsx @@ -0,0 +1,88 @@ +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react" +import { describe, expect, it } from "vitest" + +import { OnboardingPasswordScreen } from "./OnboardingPasswordScreen" + +describe("OnboardingPasswordScreen", () => { + it("onSubmit does not work until passwords match", async () => { + const onSubmit = vi.fn() + + const { container } = render( + , + ) + + const passwordElement = container.querySelector(`input[name="password"]`) + const repeatPasswordElement = container.querySelector( + `input[name="repeatPassword"]`, + ) + + expect(screen.getByText(/^Create wallet$/)).toBeDisabled() + + if (passwordElement) { + fireEvent.change(passwordElement, { target: { value: "password123" } }) + } + + fireEvent.click(screen.getByText(/^Create wallet$/)) + + await waitFor(() => expect(onSubmit).not.toHaveBeenCalled()) + + if (repeatPasswordElement) { + fireEvent.change(repeatPasswordElement, { + target: { value: "password123" }, + }) + } + + expect(screen.getByText(/^Create wallet$/)).not.toBeDisabled() + + fireEvent.click(screen.getByText(/^Create wallet$/)) + + await waitFor(() => expect(onSubmit).toHaveBeenCalledWith("password123")) + }) + + it("button text changes when loading", async () => { + const onSubmit = vi.fn( + () => + new Promise((_, rej) => { + setTimeout(rej, 200) + }), + ) + + const { rerender, container } = render( + , + ) + + const passwordElement = container.querySelector(`input[name="password"]`) + const repeatPasswordElement = container.querySelector( + `input[name="repeatPassword"]`, + ) + + if (!passwordElement || !repeatPasswordElement) { + throw new Error("Password elements not found") + } + + fireEvent.change(passwordElement, { target: { value: "password123" } }) + fireEvent.change(repeatPasswordElement, { + target: { value: "password123" }, + }) + + expect(screen.getByText(/^Create wallet$/)).not.toBeDisabled() + + fireEvent.click(screen.getByText(/^Create wallet$/)) + + await waitFor(() => expect(onSubmit).not.toHaveBeenCalled()) + + rerender() + + expect(screen.getByText(/^Creating wallet…$/)).toBeDisabled() + + await act(async () => { + await onSubmit().catch(() => { + // Do nothing + }) + }) + + rerender() + + expect(screen.getByText(/^Retry create wallet$/)).not.toBeDisabled() + }) +}) diff --git a/packages/extension/src/ui/features/onboarding/OnboardingPasswordScreen.tsx b/packages/extension/src/ui/features/onboarding/OnboardingPasswordScreen.tsx index da8bcf422..b2e9742fa 100644 --- a/packages/extension/src/ui/features/onboarding/OnboardingPasswordScreen.tsx +++ b/packages/extension/src/ui/features/onboarding/OnboardingPasswordScreen.tsx @@ -1,163 +1,116 @@ -import { FC, useCallback, useMemo, useState } from "react" +import { Box, FormControl } from "@chakra-ui/react" +import { zodResolver } from "@hookform/resolvers/zod" +import { FC, MouseEventHandler, useMemo } from "react" import { useForm } from "react-hook-form" -import { useNavigate } from "react-router-dom" -import styled from "styled-components" +import { z } from "zod" -import { useAppState } from "../../app.state" -import { StyledControlledInput } from "../../components/InputText" -import { routes } from "../../routes" -import { - analytics, - usePageTracking, - useTimeSpentWithSuccessTracking, -} from "../../services/analytics" -import { selectAccount } from "../../services/backgroundAccounts" -import { FormError } from "../../theme/Typography" -import { createAccount } from "../accounts/accounts.service" -import { validatePassword } from "../recovery/seedRecovery.state" +import { AllowPromise } from "../../../shared/storage/types" +import { ControlledInput } from "../../components/ControlledInput" +import { passwordSchema } from "../recovery/seedRecovery.state" import { OnboardingButton } from "./ui/OnboardingButton" import { OnboardingScreen } from "./ui/OnboardingScreen" -const Form = styled.form` - display: flex; - flex-direction: column; - gap: 12px; - align-self: stretch; -` +const setPasswordFormSchema = z + .object({ + password: passwordSchema, + repeatPassword: z.string(), // not using passwordSchema here, as we want to show a different error message + }) + .refine((data) => data.password === data.repeatPassword, { + message: "Passwords do not match", + path: ["repeatPassword"], + }) -const StyledOnboardingButton = styled(OnboardingButton)` - margin-top: 20px; -` +type PasswordForm = z.infer -interface FieldValues { - password: string - repeatPassword: string +export interface OnboardingPasswordScreenProps { + title?: string + submitText?: { + start: string + submitting: string + retryAfterError: string + } + onSubmit: (password: string) => AllowPromise + onBack?: MouseEventHandler } -interface NewWalletScreenProps { - overrideSubmit?: (values: { password: string }) => Promise - overrideTitle?: string - overrideSubmitText?: string -} - -export const OnboardingPasswordScreen: FC = ({ - overrideSubmit, - overrideTitle, - overrideSubmitText, +export const OnboardingPasswordScreen: FC = ({ + title = "Password", + submitText, + onBack, + onSubmit, }) => { - usePageTracking("createWallet") - const { trackSuccess } = useTimeSpentWithSuccessTracking( - "onboardingStepFinished", - { stepId: "newWalletPassword" }, - ) - const navigate = useNavigate() - const { switcherNetworkId } = useAppState() - const { control, handleSubmit, formState, watch } = useForm({ + const { + control, + handleSubmit, + setError, + formState: { errors, isDirty, isSubmitting }, + } = useForm({ criteriaMode: "firstError", + resolver: zodResolver(setPasswordFormSchema), + defaultValues: { + password: "", + repeatPassword: "", + }, }) - const { errors, isDirty } = formState - const [isDeploying, setIsDeploying] = useState(false) - const [deployFailed, setDeployFailed] = useState(false) - - const password = watch("password") - const handleDeploy = useCallback( - async (password?: string) => { - if (!password) { - return - } - if (overrideSubmit) { - useAppState.setState({ isLoading: true }) - await overrideSubmit({ password }) - useAppState.setState({ isLoading: false }) - } else { - setIsDeploying(true) - setDeployFailed(false) - try { - const newAccount = await createAccount(switcherNetworkId, password) - selectAccount(newAccount) - analytics.track("createWallet", { - status: "success", - networkId: newAccount.networkId, - }) - setIsDeploying(false) - trackSuccess() - navigate(routes.onboardingFinish.path, { replace: true }) - } catch (error: any) { - analytics.track("createWallet", { - status: "failure", - errorMessage: error.message, - networkId: switcherNetworkId, - }) - setIsDeploying(false) - setDeployFailed(true) - } - } - }, - [navigate, overrideSubmit, switcherNetworkId, trackSuccess], + const submitButtonText = useMemo( + () => + errors.root?.message + ? submitText?.retryAfterError ?? "Retry create wallet" + : isSubmitting + ? submitText?.submitting ?? "Creating wallet…" + : submitText?.start ?? "Create wallet", + [isSubmitting, errors.root?.message, submitText], ) - const buttonText = useMemo(() => { - if (overrideSubmitText) { - return overrideSubmitText + const handleForm = handleSubmit(async ({ password }) => { + try { + await onSubmit(password) + } catch (error) { + setError("root", { message: "Something went wrong" }) } - if (isDeploying) { - return "Creating wallet…" - } - return deployFailed ? "Retry create wallet" : "Create wallet" - }, [deployFailed, isDeploying, overrideSubmitText]) + }) return ( -
handleDeploy(password))}> - + - {errors.password?.type === "required" && ( - A new password is required - )} - {errors.password?.type === "validate" && ( - Password is too short - )} - x === password }} type="password" placeholder="Repeat password" - disabled={isDeploying} - variant="neutrals800" + isDisabled={isSubmitting} /> - {errors.repeatPassword?.type === "validate" && ( - Passwords do not match - )} - {deployFailed && ( - - Sorry, unable to create wallet. Please try again later. - - )} - - {buttonText} - - + + + {submitButtonText} + + +
) } diff --git a/packages/extension/src/ui/features/onboarding/OnboardingPasswordScreenContainer.tsx b/packages/extension/src/ui/features/onboarding/OnboardingPasswordScreenContainer.tsx new file mode 100644 index 000000000..d29bac25a --- /dev/null +++ b/packages/extension/src/ui/features/onboarding/OnboardingPasswordScreenContainer.tsx @@ -0,0 +1,76 @@ +import { useNavigateBack } from "@argent/ui" +import { FC, useCallback } from "react" +import { useNavigate } from "react-router-dom" + +import { accountService } from "../../../shared/account/service" +import { defaultNetwork } from "../../../shared/network" +import { routes } from "../../routes" +import { clientAccountService } from "../../services/account" +import { + analytics, + usePageTracking, + useTimeSpentWithSuccessTracking, +} from "../../services/analytics" +import { extensionService } from "../../services/extension" +import { OnboardingPasswordScreen } from "./OnboardingPasswordScreen" + +export const OnboardingPasswordScreenContainer: FC = () => { + usePageTracking("createWallet") + + const { trackSuccess } = useTimeSpentWithSuccessTracking( + "onboardingStepFinished", + { stepId: "newWalletPassword" }, + ) + + const navigate = useNavigate() + const onBack = useNavigateBack() + + // NOTE: no need to pull this from any state, as the extension was not setup yet, so defaultNetwork is fine + // we should still get rid of useAppState and any generic global state + const networkId = defaultNetwork.id + + // NOTE: I (@janek26) think we can get rid of the try/catch quite easily (see further comments), which would make this container a lot cleaner + const handleDeploy = useCallback( + async (password: string) => { + try { + await extensionService.unlock(password) + const newAccount = await clientAccountService.createAccount( + networkId, + "standard", + ) + await accountService.select(newAccount) + + // TBD: duplication of "createAccount" which comes from BG? + void analytics.track("createWallet", { + status: "success", + networkId: newAccount.networkId, + }) + // NOTE: this tracking call is legit, as it relies on information that's only accessable to the UI + // NOTE: we're not interested in the return of this promise, we should indicate that with void + void trackSuccess() + + // NOTE: return the navigate promise, to make sure the form waits for it to finish + return navigate(routes.onboardingFinish.path, { replace: true }) + } catch (error: any) { + // TBD: duplication of "createAccount" which comes from BG? + void analytics.track("createWallet", { + status: "failure", + errorMessage: error.message, + networkId: networkId, + }) + + // NOTE: by throwing the error in the submit, the form will handle that and show the retry button message to the user + throw error + } + }, + [navigate, networkId, trackSuccess], + ) + + return ( + + ) +} diff --git a/packages/extension/src/ui/features/onboarding/OnboardingPrivacyStatementScreen.test.tsx b/packages/extension/src/ui/features/onboarding/OnboardingPrivacyStatementScreen.test.tsx new file mode 100644 index 000000000..f28ae5f16 --- /dev/null +++ b/packages/extension/src/ui/features/onboarding/OnboardingPrivacyStatementScreen.test.tsx @@ -0,0 +1,42 @@ +import { render } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import { describe, expect, it, vi } from "vitest" + +import { + OnboardingPrivacyStatementScreen, + OnboardingPrivacyStatementScreenProps, +} from "./OnboardingPrivacyStatementScreen" + +describe("OnboardingPrivacyStatementScreen", () => { + const defaultProps: OnboardingPrivacyStatementScreenProps = { + onBack: vi.fn(), + } + + const renderComponent = ( + props: OnboardingPrivacyStatementScreenProps = defaultProps, + ) => { + return render() + } + + it("renders the title and privacy text", () => { + const screen = renderComponent() + + expect(screen.getByText("Privacy statement")).toBeInTheDocument() + expect( + screen.getByText(/^GDPR statement for browser extension wallet.+/), + ).toBeInTheDocument() + }) + + it("renders the back button and calls onBack when clicked", () => { + const onBack = vi.fn() + const screen = renderComponent({ + onBack, + }) + + const backButton = screen.getByText("Back") + expect(backButton).toBeInTheDocument() + + userEvent.click(backButton) + expect(onBack).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/extension/src/ui/features/onboarding/OnboardingPrivacyStatementScreen.tsx b/packages/extension/src/ui/features/onboarding/OnboardingPrivacyStatementScreen.tsx index aa4c93f5c..32f66053c 100644 --- a/packages/extension/src/ui/features/onboarding/OnboardingPrivacyStatementScreen.tsx +++ b/packages/extension/src/ui/features/onboarding/OnboardingPrivacyStatementScreen.tsx @@ -1,23 +1,22 @@ -import { FC } from "react" -import { useNavigate } from "react-router-dom" -import styled from "styled-components" +import { FC, MouseEventHandler } from "react" import { PrivacyStatementText } from "../../components/PrivacyStatementText" import { OnboardingButton } from "./ui/OnboardingButton" import { OnboardingScreen } from "./ui/OnboardingScreen" -const PrivacyStatementTextContainer = styled.div` - margin-bottom: 32px; -` +export interface OnboardingPrivacyStatementScreenProps { + onBack?: MouseEventHandler +} -export const OnboardingPrivacyStatementScreen: FC = () => { - const naviagte = useNavigate() +export const OnboardingPrivacyStatementScreen: FC< + OnboardingPrivacyStatementScreenProps +> = ({ onBack }) => { return ( - - - - - naviagte(-1)}>Back + + + + Back + ) } diff --git a/packages/extension/src/ui/features/onboarding/OnboardingPrivacyStatementScreenContainer.tsx b/packages/extension/src/ui/features/onboarding/OnboardingPrivacyStatementScreenContainer.tsx new file mode 100644 index 000000000..72402b0aa --- /dev/null +++ b/packages/extension/src/ui/features/onboarding/OnboardingPrivacyStatementScreenContainer.tsx @@ -0,0 +1,9 @@ +import { useNavigateBack } from "@argent/ui" +import { FC } from "react" + +import { OnboardingPrivacyStatementScreen } from "./OnboardingPrivacyStatementScreen" + +export const OnboardingPrivacyStatementScreenContainer: FC = () => { + const onBack = useNavigateBack() + return +} diff --git a/packages/extension/src/ui/features/onboarding/OnboardingRestoreBackup.tsx b/packages/extension/src/ui/features/onboarding/OnboardingRestoreBackup.tsx deleted file mode 100644 index fb6d0031a..000000000 --- a/packages/extension/src/ui/features/onboarding/OnboardingRestoreBackup.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import { FC, useMemo } from "react" -import { useDropzone } from "react-dropzone" -import { useNavigate } from "react-router-dom" -import styled from "styled-components" - -import { useAppState } from "../../app.state" -import { routes } from "../../routes" -import { usePageTracking } from "../../services/analytics" -import { recoverBackup } from "../../services/backgroundRecovery" -import { fileToString } from "../../services/files" -import { OnboardingButton } from "./ui/OnboardingButton" -import { OnboardingScreen } from "./ui/OnboardingScreen" - -const DropZone = styled.div` - width: 100%; - height: 256px; - display: flex; - align-items: center; - justify-content: center; - padding: 32px; - margin-bottom: 32px; - font-size: 18px; - font-weight: bold; - line-height: 24px; - text-align: center; - cursor: pointer; - border-radius: 8px; - border: 2px dashed rgba(255, 255, 255, 0.5); - background-color: ${({ theme }) => theme.black}; - - code { - font-size: 14px; - font-weight: normal; - line-height: 18px; - } -` - -export const OnboardingRestoreBackup: FC = () => { - usePageTracking("restoreWalletWithFile") - const navigate = useNavigate() - const { - acceptedFiles: [acceptedFile], - getRootProps, - getInputProps, - } = useDropzone({ - maxFiles: 1, - accept: { - "application/json": [".json"], - }, - }) - - const disableSubmit = useMemo(() => !acceptedFile, [acceptedFile]) - const handleRestoreClick = async () => { - try { - const data = await fileToString(acceptedFile) - await recoverBackup(data) - navigate(routes.onboardingFinish.path, { replace: true }) - } catch (err: any) { - const error = `${err}` - const legacyError = "legacy backup file cannot be imported" - if (error.toLowerCase().includes(legacyError)) { - navigate(routes.legacy()) - } else { - useAppState.setState({ error }) - navigate(routes.error()) - } - } - } - - return ( - - - - {disableSubmit ? ( -

Drag & drop your backup file here, or click to select it

- ) : ( -
-

Backup selected:

- {acceptedFile.name} -
- )} -
- - Restore backup - -
- ) -} diff --git a/packages/extension/src/ui/features/onboarding/OnboardingRestoreBackupScreen.test.tsx b/packages/extension/src/ui/features/onboarding/OnboardingRestoreBackupScreen.test.tsx new file mode 100644 index 000000000..5ae92b6dc --- /dev/null +++ b/packages/extension/src/ui/features/onboarding/OnboardingRestoreBackupScreen.test.tsx @@ -0,0 +1,46 @@ +import { act, fireEvent, render, waitFor } from "@testing-library/react" +import { describe, expect, it, vi } from "vitest" + +import { OnboardingRestoreBackupScreen } from "./OnboardingRestoreBackupScreen" + +describe("OnboardingRestoreBackupScreen", () => { + it("renders the title and privacy text", () => { + const onRestore = vi.fn() + const screen = render( + , + ) + + expect(screen.getByText("Select backup")).toBeInTheDocument() + expect( + screen.getByText(/^Drag & drop your backup file here.+/), + ).toBeInTheDocument() + }) + + it("enables the restore button when a file is dropped and calls onRestore with dropped file", async () => { + const onRestore = vi.fn() + const screen = render( + , + ) + const { container } = screen + const file = new File(["foo"], "foo.json", { + type: "application/json", + }) + + const fileInputElement = container.querySelector(`input[type="file"]`) + if (!fileInputElement) { + throw new Error("File input not found") + } + + expect(screen.getByText(/^Restore backup$/)).toBeDisabled() + + await act(async () => { + fireEvent.change(fileInputElement, { target: { files: [file] } }) + }) + + expect(screen.getByText(/^Restore backup$/)).not.toBeDisabled() + + fireEvent.click(screen.getByText(/^Restore backup$/)) + + await waitFor(() => expect(onRestore).toHaveBeenCalledWith(file)) + }) +}) diff --git a/packages/extension/src/ui/features/onboarding/OnboardingRestoreBackupScreen.tsx b/packages/extension/src/ui/features/onboarding/OnboardingRestoreBackupScreen.tsx new file mode 100644 index 000000000..dc4b6dc6f --- /dev/null +++ b/packages/extension/src/ui/features/onboarding/OnboardingRestoreBackupScreen.tsx @@ -0,0 +1,70 @@ +import { H6 } from "@argent/ui" +import { Center, chakra } from "@chakra-ui/react" +import { FC, MouseEventHandler, useCallback } from "react" +import { useDropzone } from "react-dropzone" + +import { OnboardingButton } from "./ui/OnboardingButton" +import { OnboardingScreen } from "./ui/OnboardingScreen" + +export interface OnboardingRestoreBackupScreenProps { + onRestore: (acceptedFile: File) => void + onBack?: MouseEventHandler +} + +export const OnboardingRestoreBackupScreen: FC< + OnboardingRestoreBackupScreenProps +> = ({ onRestore, onBack }) => { + const { + acceptedFiles: [acceptedFile], + getRootProps, + getInputProps, + } = useDropzone({ + maxFiles: 1, + accept: { + "application/json": [".json"], + }, + }) + + const disableSubmit = Boolean(!acceptedFile) + + const onSubmit = useCallback( + () => onRestore(acceptedFile), + [acceptedFile, onRestore], + ) + + return ( + +
+ + {disableSubmit ? ( +
Drag & drop your backup file here, or click to select it
+ ) : ( + <> +
Backup selected:
+ {acceptedFile.name} + + )} +
+ + Restore backup + +
+ ) +} diff --git a/packages/extension/src/ui/features/onboarding/OnboardingRestoreBackupScreenContainer.tsx b/packages/extension/src/ui/features/onboarding/OnboardingRestoreBackupScreenContainer.tsx new file mode 100644 index 000000000..3cc6a04a3 --- /dev/null +++ b/packages/extension/src/ui/features/onboarding/OnboardingRestoreBackupScreenContainer.tsx @@ -0,0 +1,41 @@ +// TBD: should we get rid of the whole file? We dont use files anymore since a long time as a backup method. Would this be part of the refactor to drop such parts of the app? As it changes functionality +import { useNavigateBack } from "@argent/ui" +import { FC, useCallback } from "react" +import { useNavigate } from "react-router-dom" + +import { useAppState } from "../../app.state" +import { routes } from "../../routes" +import { usePageTracking } from "../../services/analytics" +import { fileToString } from "../../services/files" +import { recoveryService } from "../../services/recovery" +import { useOnboardingScreen } from "./hooks/useOnboardingScreen" +import { OnboardingRestoreBackupScreen } from "./OnboardingRestoreBackupScreen" + +export const OnboardingRestoreBackupScreenContainer: FC = () => { + usePageTracking("restoreWalletWithFile") + + const navigate = useNavigate() + const onBack = useNavigateBack() + + const onRestore = useCallback( + async (acceptedFile: File) => { + try { + const data = await fileToString(acceptedFile) + await recoveryService.byBackup(data) + navigate(routes.onboardingFinish.path, { replace: true }) + } catch (err: any) { + const error = `${err}` + const legacyError = "legacy backup file cannot be imported" + if (error.toLowerCase().includes(legacyError)) { + navigate(routes.legacy()) + } else { + useAppState.setState({ error }) + navigate(routes.error()) + } + } + }, + [navigate], + ) + + return +} diff --git a/packages/extension/src/ui/features/onboarding/OnboardingRestorePassword.tsx b/packages/extension/src/ui/features/onboarding/OnboardingRestorePassword.tsx deleted file mode 100644 index 7cf1d396e..000000000 --- a/packages/extension/src/ui/features/onboarding/OnboardingRestorePassword.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { useToast } from "@argent/ui" -import { FC } from "react" -import { useNavigate } from "react-router-dom" - -import { routes } from "../../routes" -import { recoverBySeedPhrase } from "../../services/backgroundRecovery" -import { useBackupRequired } from "../recovery/backupDownload.state" -import { - useSeedRecovery, - validateAndSetPassword, - validateSeedRecoveryCompletion, -} from "../recovery/seedRecovery.state" -import { OnboardingPasswordScreen } from "./OnboardingPasswordScreen" - -export const OnboardingRestorePassword: FC = () => { - const toast = useToast() - const navigate = useNavigate() - const handleSubmit = async ({ password }: { password: string }) => { - validateAndSetPassword(password) - const state = useSeedRecovery.getState() - if (validateSeedRecoveryCompletion(state)) { - try { - const isSuccess = await recoverBySeedPhrase( - state.seedPhrase, - state.password, - ) - useBackupRequired.setState({ isBackupRequired: false }) // as the user recovered their seed, we can assume they have a backup - if (isSuccess) { - navigate(routes.onboardingFinish.path, { replace: true }) - } else { - toast({ - title: "Unable to recover the account", - status: "error", - duration: 3000, - }) - } - } catch (e) { - console.error(e) - } - } - } - return ( - - ) -} diff --git a/packages/extension/src/ui/features/onboarding/OnboardingRestorePasswordScreenContainer.tsx b/packages/extension/src/ui/features/onboarding/OnboardingRestorePasswordScreenContainer.tsx new file mode 100644 index 000000000..7d38fe7af --- /dev/null +++ b/packages/extension/src/ui/features/onboarding/OnboardingRestorePasswordScreenContainer.tsx @@ -0,0 +1,65 @@ +import { useNavigateBack, useToast } from "@argent/ui" +import { FC } from "react" +import { useNavigate } from "react-router-dom" + +import { routes } from "../../routes" +import { recoveryService } from "../../services/recovery" +import { useBackupRequired } from "../recovery/backupDownload.state" +import { + useSeedRecovery, + validateAndSetPassword, + validateSeedRecoveryCompletion, +} from "../recovery/seedRecovery.state" +import { OnboardingPasswordScreen } from "./OnboardingPasswordScreen" + +export const OnboardingRestorePasswordScreenContainer: FC = () => { + const toast = useToast() + const navigate = useNavigate() + const onBack = useNavigateBack() + + // TBD: what kind of tracking do we need here? Was the tracking from the other container correct? + + const handleSubmit = async (password: string) => { + validateAndSetPassword(password) // NOTE: password should have been validated in the form already + const state = useSeedRecovery.getState() + + try { + if (!validateSeedRecoveryCompletion(state)) { + throw new Error("Seed recovery is not complete") + } + + // should throw right away, no return value needed; to be replaced with a service which uses the new transport + const isSuccess = await recoveryService.bySeedPhrase( + state.seedPhrase, + state.password, + ) + if (!isSuccess) { + throw new Error("Unable to recover the account") + } + + useBackupRequired.setState({ isBackupRequired: false }) // as the user recovered their seed, we can assume they have a backup + + navigate(routes.onboardingFinish.path, { replace: true }) + } catch (err) { + toast({ + title: "Unable to recover the account", + status: "error", + duration: 3000, + }) + + throw err + } + } + return ( + + ) +} diff --git a/packages/extension/src/ui/features/onboarding/OnboardingRestoreSeed.tsx b/packages/extension/src/ui/features/onboarding/OnboardingRestoreSeed.tsx deleted file mode 100644 index 4726feca0..000000000 --- a/packages/extension/src/ui/features/onboarding/OnboardingRestoreSeed.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import { SeedInput } from "@argent/ui" -import { FC, useMemo, useState } from "react" -import styled from "styled-components" - -import { RowBetween } from "../../components/Row" -import { routes } from "../../routes" -import { - usePageTracking, - useTimeSpentWithSuccessTracking, -} from "../../services/analytics" -import { FormError } from "../../theme/Typography" -import { validateAndSetSeedPhrase } from "../recovery/seedRecovery.state" -import { useCustomNavigate } from "../recovery/useCustomNavigate" -import { StatusMessageBanner } from "../statusMessage/StatusMessageBanner" -import { OnboardingButton } from "./ui/OnboardingButton" -import { OnboardingScreen } from "./ui/OnboardingScreen" - -const RestoreBackupLink = styled.span` - padding: 0; - color: ${({ theme }) => theme.text3}; - font-weight: 400; - font-size: 12px; - text-decoration-line: underline; - cursor: pointer; -` - -export const OnboardingRestoreSeed: FC = () => { - usePageTracking("restoreWallet") - const { trackSuccess } = useTimeSpentWithSuccessTracking( - "onboardingStepFinished", - { stepId: "restoreSeedphrase" }, - ) - const [seedPhraseInput, setSeedPhraseInput] = useState("") - const [error, setError] = useState("") - const customNavigate = useCustomNavigate() - - const disableSubmit = useMemo( - () => Boolean(!seedPhraseInput || error), - [seedPhraseInput, error], - ) - const handleRestoreClick = async () => { - try { - validateAndSetSeedPhrase(seedPhraseInput) - trackSuccess() - customNavigate(routes.onboardingRestorePassword()) - } catch { - setError("Invalid seed phrase") - } - } - - return ( - - { - setError("") - setSeedPhraseInput(seed) - }} - /> - {error && {error}} - - { - // not possible - }} - style={{ - marginTop: "32px", - width: "100%", - }} - /> - - - - Continue - - { - customNavigate(routes.onboardingRestoreBackup()) - }} - > - Recover using a backup file - - - - ) -} diff --git a/packages/extension/src/ui/features/onboarding/OnboardingRestoreSeedScreen.test.tsx b/packages/extension/src/ui/features/onboarding/OnboardingRestoreSeedScreen.test.tsx new file mode 100644 index 000000000..0e58ba0b9 --- /dev/null +++ b/packages/extension/src/ui/features/onboarding/OnboardingRestoreSeedScreen.test.tsx @@ -0,0 +1,44 @@ +import { generateMnemonic } from "@scure/bip39" +import { wordlist as en } from "@scure/bip39/wordlists/english" +import { act, fireEvent, render, waitFor } from "@testing-library/react" +import { describe, expect, it } from "vitest" + +import { OnboardingRestoreSeedScreen } from "./OnboardingRestoreSeedScreen" + +describe("OnboardingRestoreSeedScreen", async () => { + it("onRestore is called with the seed input", async () => { + const onRestore = vi.fn() + const seed = generateMnemonic(en) + const seedSplit = seed.split(" ") + + const screen = render() + const { container } = screen + + const focusedInput = container.querySelector(":focus") + if (focusedInput) { + await act(async () => { + fireEvent.blur(focusedInput) + }) + } + + const passwordElements = container.querySelectorAll( + `input[type="password"]`, + ) + if (passwordElements.length !== 12) { + throw new Error("12 seed inputs not found") + } + + passwordElements.forEach((passwordElement, index) => { + const value = seedSplit[index] + fireEvent.change(passwordElement, { + target: { value }, + }) + }) + + await act(async () => { + fireEvent.click(screen.getByText(/^Continue$/)) + }) + + await waitFor(() => expect(onRestore).toHaveBeenCalledWith(seed)) + }) +}) diff --git a/packages/extension/src/ui/features/onboarding/OnboardingRestoreSeedScreen.tsx b/packages/extension/src/ui/features/onboarding/OnboardingRestoreSeedScreen.tsx new file mode 100644 index 000000000..ac56ab394 --- /dev/null +++ b/packages/extension/src/ui/features/onboarding/OnboardingRestoreSeedScreen.tsx @@ -0,0 +1,101 @@ +import { Alert, B3, FieldError, SeedInput, icons } from "@argent/ui" +import { Flex, chakra } from "@chakra-ui/react" +import { zodResolver } from "@hookform/resolvers/zod" +import { FC, MouseEventHandler } from "react" +import { Controller, useForm } from "react-hook-form" +import { z } from "zod" + +import { seedphraseSchema } from "../../../shared/schemas/seedphrase" +import { OnboardingButton } from "./ui/OnboardingButton" +import { OnboardingScreen } from "./ui/OnboardingScreen" + +const { AlertIcon } = icons + +interface OnboardingRestoreSeedScreenProps { + onBack?: MouseEventHandler + onRestore: (seedPhrase: string) => Promise + onUseBackup?: MouseEventHandler +} + +const seedFormSchema = z.object({ + seedPhrase: seedphraseSchema, +}) + +export const OnboardingRestoreSeedScreen: FC< + OnboardingRestoreSeedScreenProps +> = ({ onBack, onRestore, onUseBackup }) => { + const { + control, + formState: { errors, isSubmitting }, + handleSubmit, + setError, + } = useForm({ + resolver: zodResolver(seedFormSchema), + defaultValues: { + seedPhrase: "", + }, + }) + + const handleForm = handleSubmit(async ({ seedPhrase }) => { + try { + await onRestore(seedPhrase) + } catch (error) { + setError("root", { message: "Something went wrong" }) + } + }) + + return ( + + + ( + + )} + /> + {errors.seedPhrase?.message && ( + {errors.seedPhrase?.message} + )} + + } + colorScheme={"warning"} + title="Typing is safer" + description="You can paste your recovery phrase at once, but typing the words individually is safer" + /> + + + + Continue + + + Recover using a backup file + + + + {errors.root?.message && ( + {errors.root?.message} + )} + + + ) +} diff --git a/packages/extension/src/ui/features/onboarding/OnboardingRestoreSeedScreenContainer.tsx b/packages/extension/src/ui/features/onboarding/OnboardingRestoreSeedScreenContainer.tsx new file mode 100644 index 000000000..d8fa15d18 --- /dev/null +++ b/packages/extension/src/ui/features/onboarding/OnboardingRestoreSeedScreenContainer.tsx @@ -0,0 +1,44 @@ +import { useNavigateBack } from "@argent/ui" +import { FC, useCallback } from "react" + +import { routes } from "../../routes" +import { + usePageTracking, + useTimeSpentWithSuccessTracking, +} from "../../services/analytics" +import { useCustomNavigate } from "../recovery/hooks/useCustomNavigate" +import { useSeedRecovery } from "../recovery/seedRecovery.state" +import { OnboardingRestoreSeedScreen } from "./OnboardingRestoreSeedScreen" + +export const OnboardingRestoreSeedScreenContainer: FC = () => { + usePageTracking("restoreWallet") + + const { trackSuccess } = useTimeSpentWithSuccessTracking( + "onboardingStepFinished", + { stepId: "restoreSeedphrase" }, + ) + const customNavigate = useCustomNavigate() + const onBack = useNavigateBack() + + const onRestore = useCallback( + async (seedPhrase: string) => { + // seedPhrase was already validated in the OnboardingRestoreSeedScreen form and will be validated again in the BG + useSeedRecovery.setState({ seedPhrase }) // set to temorary state, so we can access it after the next screen + void trackSuccess() + await customNavigate(routes.onboardingRestorePassword()) + }, + [customNavigate, trackSuccess], + ) + + const onUseBackup = useCallback(() => { + customNavigate(routes.onboardingRestoreBackup()) + }, [customNavigate]) + + return ( + + ) +} diff --git a/packages/extension/src/ui/features/onboarding/OnboardingStartScreen.test.tsx b/packages/extension/src/ui/features/onboarding/OnboardingStartScreen.test.tsx new file mode 100644 index 000000000..635a83a3e --- /dev/null +++ b/packages/extension/src/ui/features/onboarding/OnboardingStartScreen.test.tsx @@ -0,0 +1,19 @@ +import { fireEvent, render, screen } from "@testing-library/react" +import { describe, expect, test } from "vitest" + +import { OnboardingStartScreen } from "./OnboardingStartScreen" + +describe("OnboardingStartScreen", () => { + test("calls onCreate or onRestore when appropriate button is clicked", () => { + const onCreate = vi.fn() + const onRestore = vi.fn() + + render() + + fireEvent.click(screen.getByText(/^Create/)) + fireEvent.click(screen.getByText(/^Restore/)) + + expect(onCreate).toHaveBeenCalled() + expect(onRestore).toHaveBeenCalled() + }) +}) diff --git a/packages/extension/src/ui/features/onboarding/OnboardingStartScreen.tsx b/packages/extension/src/ui/features/onboarding/OnboardingStartScreen.tsx index ceadd3ba2..696d30c6a 100644 --- a/packages/extension/src/ui/features/onboarding/OnboardingStartScreen.tsx +++ b/packages/extension/src/ui/features/onboarding/OnboardingStartScreen.tsx @@ -1,50 +1,23 @@ -import { FC, useEffect, useRef } from "react" -import { useNavigate } from "react-router-dom" +import { icons } from "@argent/ui" +import { Circle, SimpleGrid } from "@chakra-ui/react" +import { FC, MouseEventHandler } from "react" -import { - AccountBalanceWalletIcon, - RefreshIcon, -} from "../../components/Icons/MuiIcons" -import Row from "../../components/Row" -import { routes } from "../../routes" -import { - usePageTracking, - useTimeSpentWithSuccessTracking, -} from "../../services/analytics" -import { extensionIsInTab, openExtensionInTab } from "../browser/tabs" +import { OnboardingRectButton } from "./ui/OnboardingRectButton" import { OnboardingScreen } from "./ui/OnboardingScreen" -import { - CreateWalletRectButtonIcon, - RectButton, - RestoreWalletRectButtonIcon, -} from "./ui/RectButton" -export const OnboardingStartScreen: FC = () => { - const didRunInit = useRef(false) - const navigate = useNavigate() - usePageTracking("welcome") - const { trackSuccess } = useTimeSpentWithSuccessTracking( - "onboardingStepFinished", - { stepId: "welcome" }, - ) +const { WalletIcon, RestoreIcon } = icons - useEffect(() => { - const init = async () => { - /** prevent opening more than once when useEffect is called multiple times in dev */ - if (!didRunInit.current) { - didRunInit.current = true - /** When user clicks extension icon, open onboarding in full screen */ - const inTab = await extensionIsInTab() - if (!inTab) { - /** Note: cannot detect and focus an existing extension tab here, so open a new one */ - await openExtensionInTab() - window.close() - } - } - } - init() - }, []) +interface OnboardingStartScreenProps { + /** Called when user clicks to create a new wallet */ + onCreate: MouseEventHandler + /** Called when user clicks to restore an existing wallet */ + onRestore: MouseEventHandler +} +export const OnboardingStartScreen: FC = ({ + onCreate, + onRestore, +}) => { return ( { title="Welcome to Argent X" subtitle="Enjoy the security of Ethereum with the scale of StarkNet" > - - { - trackSuccess() - navigate(routes.onboardingDisclaimer()) - }} - > - - - + + + + + Create a new wallet - - { - trackSuccess() - navigate(routes.onboardingRestoreSeed()) - }} - > - - - + + + + + Restore an existing wallet - - + + ) } diff --git a/packages/extension/src/ui/features/onboarding/OnboardingStartScreenContainer.tsx b/packages/extension/src/ui/features/onboarding/OnboardingStartScreenContainer.tsx new file mode 100644 index 000000000..c95e27b3b --- /dev/null +++ b/packages/extension/src/ui/features/onboarding/OnboardingStartScreenContainer.tsx @@ -0,0 +1,31 @@ +import { FC, useCallback } from "react" +import { useNavigate } from "react-router-dom" + +import { routes } from "../../routes" +import { + usePageTracking, + useTimeSpentWithSuccessTracking, +} from "../../services/analytics" +import { OnboardingStartScreen } from "./OnboardingStartScreen" + +export const OnboardingStartScreenContainer: FC = () => { + usePageTracking("welcome") + const { trackSuccess } = useTimeSpentWithSuccessTracking( + "onboardingStepFinished", + { stepId: "welcome" }, + ) + + const navigate = useNavigate() + + const onCreate = useCallback(() => { + void trackSuccess() + void navigate(routes.onboardingDisclaimer()) + }, [navigate, trackSuccess]) + + const onRestore = useCallback(() => { + void trackSuccess() // NOTE: there is nothing different between restore and create, so we track the same event? + void navigate(routes.onboardingRestoreSeed()) + }, [navigate, trackSuccess]) + + return +} diff --git a/packages/extension/src/ui/features/onboarding/StickyArgentFooter.tsx b/packages/extension/src/ui/features/onboarding/StickyArgentFooter.tsx deleted file mode 100644 index 3fbda2b0a..000000000 --- a/packages/extension/src/ui/features/onboarding/StickyArgentFooter.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { FC } from "react" -import styled from "styled-components" - -import { ResponsiveFixedBox } from "../../components/Responsive" - -const Container = styled(ResponsiveFixedBox)` - bottom: 50px; - left: 0; - right: 0; - - font-weight: 600; - font-size: 12px; - line-height: 14px; - text-align: center; - color: #fff; -` - -const EmojiWrapper = styled.span` - margin-right: 0.5em; -` - -export const StickyArgentFooter: FC = () => ( - - Built with ❤️ - by Argent - -) diff --git a/packages/extension/src/ui/features/onboarding/hooks/useOnboardingScreen.ts b/packages/extension/src/ui/features/onboarding/hooks/useOnboardingScreen.ts new file mode 100644 index 000000000..fe1dde607 --- /dev/null +++ b/packages/extension/src/ui/features/onboarding/hooks/useOnboardingScreen.ts @@ -0,0 +1,59 @@ +import { useEffect, useRef } from "react" +import { useLocation, useNavigate } from "react-router-dom" + +import { useAppState } from "../../../app.state" +import { routes } from "../../../routes" +import { isInitialized } from "../../../services/backgroundSessions" +import { extensionIsInTab, openExtensionInTab } from "../../browser/tabs" + +/** + * This hook is used to redirect to the finish screen if the wallet is already initialised + * It also checks if the extension is in a tab, if not, it opens it in a tab + * + * @dev This hook should be called on every screen of the onboarding + * @returns void + */ +export const useOnboardingScreen = () => { + const navigate = useNavigate() + const location = useLocation() + const { isFirstRender } = useAppState() + // TODO: this should not be nessessary, instead of pulling a value on focus, the UI should react to changes in real time by subscribing to the store + useEffect(() => { + /** on window focus, check if the wallet was initialised elsewhere and redirect to finish screen */ + const onFocus = async () => { + const { initialized } = await isInitialized() + if ( + initialized && + isFirstRender && + location.pathname !== routes.onboardingFinish.path && + location.pathname !== routes.onboardingRestorePassword.path // feels very hacky this useEffect here, need to find something more sustainable + ) { + navigate(routes.onboardingFinish.path, { replace: true }) + } + } + window.addEventListener("focus", onFocus) + onFocus() + return () => { + window.removeEventListener("focus", onFocus) + } + }, [location.pathname, navigate, isFirstRender]) + + // NOTE: check if extension is in a tab, if not, open it in a tab + const didRunInit = useRef(false) + useEffect(() => { + const init = async () => { + /** prevent opening more than once when useEffect is called multiple times in dev */ + if (!didRunInit.current) { + didRunInit.current = true + /** When user clicks extension icon, open onboarding in full screen */ + const inTab = await extensionIsInTab() + if (!inTab) { + /** Note: cannot detect and focus an existing extension tab here, so open a new one */ + await openExtensionInTab() + window.close() + } + } + } + init() + }, [isFirstRender]) +} diff --git a/packages/extension/src/ui/features/onboarding/hooks/useOnboardingToastMessage.tsx b/packages/extension/src/ui/features/onboarding/hooks/useOnboardingToastMessage.tsx new file mode 100644 index 000000000..a6afb680a --- /dev/null +++ b/packages/extension/src/ui/features/onboarding/hooks/useOnboardingToastMessage.tsx @@ -0,0 +1,19 @@ +import { useToast } from "@chakra-ui/react" +import { useEffect, useRef } from "react" + +import { OnboardingToastMessage } from "../ui/OnboardingToastMessage" + +export const useOnboardingToastMessage = () => { + const toast = useToast() + const didTriggerToast = useRef(false) + useEffect(() => { + if (!didTriggerToast.current) { + didTriggerToast.current = true + toast({ + position: "top-right", + isClosable: false, + render: () => , + }) + } + }, [toast]) +} diff --git a/packages/extension/src/ui/features/onboarding/ui/OnboardingButton.tsx b/packages/extension/src/ui/features/onboarding/ui/OnboardingButton.tsx index 1d8634d38..b4c054ce4 100644 --- a/packages/extension/src/ui/features/onboarding/ui/OnboardingButton.tsx +++ b/packages/extension/src/ui/features/onboarding/ui/OnboardingButton.tsx @@ -1,9 +1,6 @@ -import styled from "styled-components" +import { Button } from "@argent/ui" +import { ComponentProps, FC } from "react" -import { PressableButton } from "../../../components/Button" - -export const OnboardingButton = styled(PressableButton).attrs(() => ({ - variant: "primary", -}))` - width: 200px; -` +export const OnboardingButton: FC> = (props) => { + return )} {indicator && ( )} -
- {title && {title}} - {subtitle && {subtitle}} -
+ + {title &&

{title}

} + {subtitle && {subtitle}} +
{children}
- {icon} - + {icon} + ) } diff --git a/packages/extension/src/ui/features/onboarding/ui/OnboardingToastMessage.tsx b/packages/extension/src/ui/features/onboarding/ui/OnboardingToastMessage.tsx new file mode 100644 index 000000000..566676a3a --- /dev/null +++ b/packages/extension/src/ui/features/onboarding/ui/OnboardingToastMessage.tsx @@ -0,0 +1,31 @@ +import { H6, P3, icons, logos } from "@argent/ui" +import { Flex } from "@chakra-ui/react" +import { FC } from "react" + +const { ChromeExtensionIcon, PinIcon } = icons +const { ArgentXLogo } = logos + +export const OnboardingToastMessage: FC = () => { + return ( + + + + Pin the Argent X extension for quick access + + + +
Argent X
+ +
+
+ ) +} diff --git a/packages/extension/src/ui/features/onboarding/ui/RectButton.tsx b/packages/extension/src/ui/features/onboarding/ui/RectButton.tsx deleted file mode 100644 index 654104248..000000000 --- a/packages/extension/src/ui/features/onboarding/ui/RectButton.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { colord } from "colord" -import styled from "styled-components" - -import { PressableButton } from "../../../components/Button" - -export const RectButton = styled(PressableButton).attrs(() => ({ - variant: "neutrals800", -}))` - display: flex; - flex-direction: column; - gap: 8px; - border-radius: 8px; - align-items: center; - justify-content: center; - padding: 36px 18px; -` - -export const IconContainer = styled.div` - width: 64px; - height: 64px; - border-radius: 500px; - display: flex; - align-items: center; - justify-content: center; -` - -export const CreateWalletRectButtonIcon = styled(IconContainer)` - background-color: ${({ theme }) => theme.primary}; -` - -export const RestoreWalletRectButtonIcon = styled(IconContainer)` - background-color: ${({ theme }) => - colord(theme.neutrals600).alpha(0.5).toRgbString()}; -` - -export const TwitterRectButtonIcon = styled(IconContainer)` - background-color: #1da1f2; -` - -export const DiscordRectButtonIcon = styled(IconContainer)` - background-color: #5865f2; -` diff --git a/packages/extension/src/ui/features/recovery/BackupDownloadScreen.tsx b/packages/extension/src/ui/features/recovery/BackupDownloadScreen.tsx deleted file mode 100644 index 0e0db8aa2..000000000 --- a/packages/extension/src/ui/features/recovery/BackupDownloadScreen.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { BarBackButton, NavigationContainer } from "@argent/ui" -import { FC, FormEventHandler } from "react" -import { useLocation, useNavigate } from "react-router-dom" -import styled from "styled-components" - -import { Button } from "../../components/Button" -import { routes } from "../../routes" -import { downloadBackupFile } from "../../services/backgroundRecovery" -import { H2, P } from "../../theme/Typography" -import { useBackupRequired } from "./backupDownload.state" - -const DownloadButton = styled(Button)` - margin-top: auto; -` - -const Container = styled.div` - padding: 0px 40px 24px; - display: flex; - flex-direction: column; - height: calc(100vh - 68px); -` - -export const BackupDownloadScreen: FC = () => { - const navigate = useNavigate() - const { search } = useLocation() - - const isSettings = new URLSearchParams(search).has("settings") - - const handleDownloadClick: FormEventHandler = async () => { - downloadBackupFile() - useBackupRequired.setState({ isBackupRequired: false }) - navigate(isSettings ? routes.settings() : routes.accountTokens()) - } - - return ( - : null}> - -

Download your backup

-

- This is encrypted by your password and required if you need to restore - your accounts. -

-

- Each time you add a new account, you'll be prompted to download - an updated backup file for all your accounts. -

- Download -
-
- ) -} diff --git a/packages/extension/src/ui/features/recovery/CopySeedPhrase.tsx b/packages/extension/src/ui/features/recovery/CopySeedPhrase.tsx index aab5541db..b4743fa9c 100644 --- a/packages/extension/src/ui/features/recovery/CopySeedPhrase.tsx +++ b/packages/extension/src/ui/features/recovery/CopySeedPhrase.tsx @@ -1,40 +1,10 @@ import { FC, useEffect, useState } from "react" import CopyToClipboard from "react-copy-to-clipboard" -import styled from "styled-components" -import { Button } from "../../components/Button" import { ColumnCenter } from "../../components/Column" import { WarningIconRounded } from "../../components/Icons/WarningIconRounded" - -const CopySeedPhraseButton = styled(Button)<{ active: boolean }>` - padding: 6px 12px; - background: ${({ active }) => - active ? "#FFFFFF" : "rgba(255, 255, 255, 0.25)"}; - color: ${({ active }) => (active ? "#000" : "#fff")}; - border-radius: 100px; - width: max-content; - font-weight: 600; - font-size: 15px; - line-height: 20px; - - display: flex; - align-items: center; - justify-content: center; - gap: 6px; - - :active, - :focus { - background: ${({ active }) => - active ? "#FFFFFF" : "rgba(255, 255, 255, 0.25)"}; - } -` - -const WarningText = styled.div` - text-align: center; - color: ${({ theme }) => theme.yellow1}; - font-size: 12px; - line-height: 16px; -` +import { CopySeedPhraseButton } from "./ui/CopySeedPhraseButton" +import { WarningText } from "./ui/WarningText" export const CopySeedPhrase: FC<{ seedPhrase?: string }> = ({ seedPhrase }) => { const [seedPhraseCopied, setSeedPhraseCopied] = useState(false) @@ -62,7 +32,7 @@ export const CopySeedPhrase: FC<{ seedPhrase?: string }> = ({ seedPhrase }) => { onCopy={() => setSeedPhraseCopied(true)} text={seedPhrase} > - + {seedPhraseCopied ? "Copied" : "Copy"} diff --git a/packages/extension/src/ui/features/recovery/RecoverySetupScreen.tsx b/packages/extension/src/ui/features/recovery/RecoverySetupScreen.tsx index 7ae9a8c82..3b362757e 100644 --- a/packages/extension/src/ui/features/recovery/RecoverySetupScreen.tsx +++ b/packages/extension/src/ui/features/recovery/RecoverySetupScreen.tsx @@ -1,26 +1,15 @@ import { BarCloseButton, NavigationContainer, icons } from "@argent/ui" import { FC } from "react" import { Link, useNavigate } from "react-router-dom" -import styled from "styled-components" import { Option, OptionsWrapper } from "../../components/Options" import { PageWrapper, Paragraph, Title } from "../../components/Page" import { routes, useReturnTo } from "../../routes" +import { CircleIconContainer } from "./ui/CircleIconContainer" +import { ComingSoonIcon } from "./ui/ComingSoonIcon" const { RestoreIcon } = icons -const CircleIconContainer = styled.div` - border-radius: 500px; - display: flex; - font-size: 24px; - align-items: center; - justify-content: center; - width: 32px; - height: 32px; - color: ${({ theme }) => theme.neutrals600}; - background-color: ${({ theme }) => theme.white}; -` - export const RecoverySetupScreen: FC = () => { const navigate = useNavigate() const returnTo = useReturnTo() @@ -43,24 +32,7 @@ export const RecoverySetupScreen: FC = () => { title="With Argent guardian" description="Coming soon" disabled - icon={ - - - - - } + icon={} />