From 19d2ebf194bbd23020fa9913fb6a1b1df3ccde16 Mon Sep 17 00:00:00 2001 From: Eric Corson Date: Thu, 29 Feb 2024 11:01:38 +0900 Subject: [PATCH 1/5] fix: wait for shared to init before rendering app (#641) --- apps/namada-interface/src/index.tsx | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/apps/namada-interface/src/index.tsx b/apps/namada-interface/src/index.tsx index a7a59a9976..49c3400b76 100644 --- a/apps/namada-interface/src/index.tsx +++ b/apps/namada-interface/src/index.tsx @@ -11,21 +11,23 @@ import reportWebVitals from "./reportWebVitals"; import "@namada/components/src/base.css"; import "./tailwind.css"; -ReactDOM.render( - - - - - - - - - , - document.getElementById("root") -); +// TODO: we could show the loading screen while initShared is pending +initShared().then(() => { + ReactDOM.render( + + + + + + + + + , + document.getElementById("root") + ); +}); // If you want to start measuring performance in your app, pass a function // to log results (for example: reportWebVitals(console.log)) // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals reportWebVitals(); -initShared(); From b50525079c7d527e69f11e51e627b25b9048b674 Mon Sep 17 00:00:00 2001 From: Eric Corson Date: Thu, 29 Feb 2024 11:04:48 +0900 Subject: [PATCH 2/5] feat: add isConnected method and revoke event (#641) --- .../src/background/approvals/handler.ts | 14 +++++++++++ .../src/background/approvals/init.ts | 2 ++ .../src/background/approvals/service.test.ts | 6 ++++- .../src/background/approvals/service.ts | 23 +++++++++++++----- apps/extension/src/background/index.ts | 3 ++- apps/extension/src/content/events.ts | 24 +++++++++++++++++++ .../src/extension/ExtensionBroadcaster.ts | 5 ++++ apps/extension/src/provider/InjectedNamada.ts | 6 ++++- apps/extension/src/provider/Namada.ts | 14 ++++++++++- apps/extension/src/provider/Proxy.ts | 12 ++++++---- apps/extension/src/provider/messages.ts | 24 ++++++++++++++++++- apps/extension/src/test/init.ts | 5 ++-- packages/integrations/src/Namada.ts | 6 ++++- packages/types/src/events.ts | 1 + packages/types/src/namada.ts | 1 + 15 files changed, 128 insertions(+), 18 deletions(-) diff --git a/apps/extension/src/background/approvals/handler.ts b/apps/extension/src/background/approvals/handler.ts index dcb8af7694..4c5604ba50 100644 --- a/apps/extension/src/background/approvals/handler.ts +++ b/apps/extension/src/background/approvals/handler.ts @@ -2,6 +2,7 @@ import { ApproveConnectInterfaceMsg, ApproveSignArbitraryMsg, ApproveTxMsg, + IsConnectionApprovedMsg, } from "provider"; import { Env, Handler, InternalHandler, Message } from "router"; import { @@ -26,6 +27,11 @@ export const getHandler: (service: ApprovalsService) => Handler = (service) => { env, msg as SubmitApprovedTxMsg ); + case IsConnectionApprovedMsg: + return handleIsConnectionApprovedMsg(service)( + env, + msg as IsConnectionApprovedMsg + ); case ApproveConnectInterfaceMsg: return handleApproveConnectInterfaceMsg(service)( env, @@ -87,6 +93,14 @@ const handleSubmitApprovedTxMsg: ( }; }; +const handleIsConnectionApprovedMsg: ( + service: ApprovalsService +) => InternalHandler = (service) => { + return async (_, { origin }) => { + return await service.isConnectionApproved(origin); + }; +}; + const handleApproveConnectInterfaceMsg: ( service: ApprovalsService ) => InternalHandler = (service) => { diff --git a/apps/extension/src/background/approvals/init.ts b/apps/extension/src/background/approvals/init.ts index 2513c54d5e..9746575b6c 100644 --- a/apps/extension/src/background/approvals/init.ts +++ b/apps/extension/src/background/approvals/init.ts @@ -2,6 +2,7 @@ import { ApproveConnectInterfaceMsg, ApproveSignArbitraryMsg, ApproveTxMsg, + IsConnectionApprovedMsg, } from "provider"; import { Router } from "router"; import { @@ -24,6 +25,7 @@ export function init(router: Router, service: ApprovalsService): void { router.registerMessage(ApproveSignArbitraryMsg); router.registerMessage(RejectSignatureMsg); router.registerMessage(SubmitApprovedSignatureMsg); + router.registerMessage(IsConnectionApprovedMsg); router.registerMessage(ApproveConnectInterfaceMsg); router.registerMessage(ConnectInterfaceResponseMsg); router.registerMessage(RevokeConnectionMsg); diff --git a/apps/extension/src/background/approvals/service.test.ts b/apps/extension/src/background/approvals/service.test.ts index 35b5c5f519..e7342143fe 100644 --- a/apps/extension/src/background/approvals/service.test.ts +++ b/apps/extension/src/background/approvals/service.test.ts @@ -16,6 +16,7 @@ import { KeyRingService, TabStore } from "background/keyring"; import { LedgerService } from "background/ledger"; import { VaultService } from "background/vault"; import BigNumber from "bignumber.js"; +import { ExtensionBroadcaster } from "extension"; import createMockInstance from "jest-create-mock-instance"; import { KVStoreMock } from "test/init"; import { ApprovalsService } from "./service"; @@ -55,6 +56,8 @@ describe.only("approvals service", () => { const vaultService: jest.Mocked = createMockInstance( VaultService as any ); + const broadcaster: jest.Mocked = + createMockInstance(ExtensionBroadcaster); service = new ApprovalsService( txStore, @@ -63,7 +66,8 @@ describe.only("approvals service", () => { approvedOriginsStore, keyRingService, ledgerService, - vaultService + vaultService, + broadcaster ); }); diff --git a/apps/extension/src/background/approvals/service.ts b/apps/extension/src/background/approvals/service.ts index 03eff62b7e..576c4263eb 100644 --- a/apps/extension/src/background/approvals/service.ts +++ b/apps/extension/src/background/approvals/service.ts @@ -22,8 +22,8 @@ import { import { assertNever, paramsToUrl } from "@namada/utils"; import { KeyRingService, TabStore } from "background/keyring"; import { LedgerService } from "background/ledger"; - import { VaultService } from "background/vault"; +import { ExtensionBroadcaster } from "extension"; import { ApprovedOriginsStore, TxStore } from "./types"; import { APPROVED_ORIGINS_KEY, @@ -52,7 +52,8 @@ export class ApprovalsService { protected readonly approvedOriginsStore: KVStore, protected readonly keyRingService: KeyRingService, protected readonly ledgerService: LedgerService, - protected readonly vaultService: VaultService + protected readonly vaultService: VaultService, + protected readonly broadcaster: ExtensionBroadcaster ) {} async approveSignature( @@ -344,6 +345,13 @@ export class ApprovalsService { return await this._clearPendingTx(msgId); } + async isConnectionApproved(interfaceOrigin: string): Promise { + const approvedOrigins = + (await this.approvedOriginsStore.get(APPROVED_ORIGINS_KEY)) || []; + + return approvedOrigins.includes(interfaceOrigin); + } + async approveConnection( interfaceTabId: number, interfaceOrigin: string @@ -357,10 +365,9 @@ export class ApprovalsService { interfaceOrigin, }); - const approvedOrigins = - (await this.approvedOriginsStore.get(APPROVED_ORIGINS_KEY)) || []; + const alreadyApproved = await this.isConnectionApproved(interfaceOrigin); - if (!approvedOrigins.includes(interfaceOrigin)) { + if (!alreadyApproved) { const approvalWindow = await this._launchApprovalWindow(url); const popupTabId = approvalWindow.tabs?.[0]?.id; @@ -376,6 +383,9 @@ export class ApprovalsService { this.resolverMap[popupTabId] = { resolve, reject }; }); } + + // A resolved promise is implicitly returned here if the origin had + // previously been approved. } async approveConnectionResponse( @@ -403,7 +413,8 @@ export class ApprovalsService { } async revokeConnection(originToRevoke: string): Promise { - return removeApprovedOrigin(this.approvedOriginsStore, originToRevoke); + await removeApprovedOrigin(this.approvedOriginsStore, originToRevoke); + await this.broadcaster.revokeConnection(); } private async _clearPendingTx(msgId: string): Promise { diff --git a/apps/extension/src/background/index.ts b/apps/extension/src/background/index.ts index e3fc72ff38..5dc9304128 100644 --- a/apps/extension/src/background/index.ts +++ b/apps/extension/src/background/index.ts @@ -113,7 +113,8 @@ const init = new Promise(async (resolve) => { approvedOriginsStore, keyRingService, ledgerService, - vaultService + vaultService, + broadcaster ); // Initialize messages and handlers diff --git a/apps/extension/src/content/events.ts b/apps/extension/src/content/events.ts index abd1e12cb0..12e783cc58 100644 --- a/apps/extension/src/content/events.ts +++ b/apps/extension/src/content/events.ts @@ -201,6 +201,26 @@ export class VaultLockedEventMsg extends Message { } } +export class ConnectionRevokedEventMsg extends Message { + public static type(): Events { + return Events.ConnectionRevoked; + } + + constructor() { + super(); + } + + validate(): void {} + + route(): string { + return Routes.InteractionForeground; + } + + type(): string { + return ConnectionRevokedEventMsg.type(); + } +} + export function initEvents(router: Router): void { router.registerMessage(AccountChangedEventMsg); router.registerMessage(NetworkChangedEventMsg); @@ -210,6 +230,7 @@ export function initEvents(router: Router): void { router.registerMessage(TxStartedEvent); router.registerMessage(TxCompletedEvent); router.registerMessage(VaultLockedEventMsg); + router.registerMessage(ConnectionRevokedEventMsg); router.addHandler(Routes.InteractionForeground, (_, msg) => { const clonedMsg = @@ -248,6 +269,9 @@ export function initEvents(router: Router): void { case VaultLockedEventMsg: window.dispatchEvent(new CustomEvent(Events.ExtensionLocked)); break; + case ConnectionRevokedEventMsg: + window.dispatchEvent(new CustomEvent(Events.ConnectionRevoked)); + break; default: throw new Error("Unknown msg type"); } diff --git a/apps/extension/src/extension/ExtensionBroadcaster.ts b/apps/extension/src/extension/ExtensionBroadcaster.ts index 9751919679..7bbb496ec5 100644 --- a/apps/extension/src/extension/ExtensionBroadcaster.ts +++ b/apps/extension/src/extension/ExtensionBroadcaster.ts @@ -3,6 +3,7 @@ import { KVStore } from "@namada/storage"; import { TabStore, syncTabs } from "background/keyring"; import { AccountChangedEventMsg, + ConnectionRevokedEventMsg, NetworkChangedEventMsg, ProposalsUpdatedEventMsg, TxCompletedEvent, @@ -59,6 +60,10 @@ export class ExtensionBroadcaster { await this.sendMsgToTabs(new VaultLockedEventMsg()); } + async revokeConnection(): Promise { + await this.sendMsgToTabs(new ConnectionRevokedEventMsg()); + } + /** * Query all existing tabs, and send provided message to each */ diff --git a/apps/extension/src/provider/InjectedNamada.ts b/apps/extension/src/provider/InjectedNamada.ts index 7bd3d6a6a8..bbcbae86f8 100644 --- a/apps/extension/src/provider/InjectedNamada.ts +++ b/apps/extension/src/provider/InjectedNamada.ts @@ -13,12 +13,16 @@ import { InjectedProxy } from "./InjectedProxy"; import { Signer } from "./Signer"; export class InjectedNamada implements INamada { - constructor(private readonly _version: string) { } + constructor(private readonly _version: string) {} public async connect(): Promise { return await InjectedProxy.requestMethod("connect"); } + public async isConnected(): Promise { + return await InjectedProxy.requestMethod("isConnected"); + } + public async accounts(): Promise { return await InjectedProxy.requestMethod( "accounts" diff --git a/apps/extension/src/provider/Namada.ts b/apps/extension/src/provider/Namada.ts index 273b44a5af..d00cb4e487 100644 --- a/apps/extension/src/provider/Namada.ts +++ b/apps/extension/src/provider/Namada.ts @@ -18,6 +18,7 @@ import { FetchAndStoreMaspParamsMsg, GetChainMsg, HasMaspParamsMsg, + IsConnectionApprovedMsg, QueryAccountsMsg, QueryBalancesMsg, QueryDefaultAccountMsg, @@ -28,7 +29,7 @@ export class Namada implements INamada { constructor( private readonly _version: string, protected readonly requester?: MessageRequester - ) { } + ) {} public async connect(): Promise { return await this.requester?.sendMessage( @@ -37,6 +38,17 @@ export class Namada implements INamada { ); } + public async isConnected(): Promise { + if (!this.requester) { + throw new Error("no requester"); + } + + return await this.requester.sendMessage( + Ports.Background, + new IsConnectionApprovedMsg() + ); + } + public async accounts( // TODO: This argument should be removed in the future! _chainId?: string diff --git a/apps/extension/src/provider/Proxy.ts b/apps/extension/src/provider/Proxy.ts index 2761e181a6..b970bcb411 100644 --- a/apps/extension/src/provider/Proxy.ts +++ b/apps/extension/src/provider/Proxy.ts @@ -1,5 +1,8 @@ import { KVStore } from "@namada/storage"; -import { ApprovedOriginsStore, APPROVED_ORIGINS_KEY } from "background/approvals"; +import { + APPROVED_ORIGINS_KEY, + ApprovedOriginsStore, +} from "background/approvals"; import { Namada } from "./Namada"; import { ProxyRequest, ProxyRequestResponse, ProxyRequestTypes } from "./types"; @@ -7,7 +10,7 @@ import { ProxyRequest, ProxyRequestResponse, ProxyRequestTypes } from "./types"; export class Proxy { static start( namada: Namada, - approvedOriginsStore: KVStore, + approvedOriginsStore: KVStore ): void { Proxy.addMessageListener(async (e) => { const message = e.data; @@ -18,8 +21,9 @@ export class Proxy { const { method, args } = message; - if (method !== "connect") { - const approvedOrigins = await approvedOriginsStore.get(APPROVED_ORIGINS_KEY) || []; + if (method !== "connect" && method !== "isConnected") { + const approvedOrigins = + (await approvedOriginsStore.get(APPROVED_ORIGINS_KEY)) || []; if (!approvedOrigins.includes(e.origin)) { return; } diff --git a/apps/extension/src/provider/messages.ts b/apps/extension/src/provider/messages.ts index 90f16b7421..c89ef1398d 100644 --- a/apps/extension/src/provider/messages.ts +++ b/apps/extension/src/provider/messages.ts @@ -21,6 +21,7 @@ enum Route { } enum MessageType { + IsConnectionApproved = "is-connection-approved", ApproveConnectInterface = "approve-connect-interface", QueryAccounts = "query-accounts", QueryDefaultAccount = "query-default-account", @@ -42,6 +43,27 @@ enum MessageType { /** * Messages routed from providers to Chains service */ +export class IsConnectionApprovedMsg extends Message { + public static type(): MessageType { + return MessageType.IsConnectionApproved; + } + + constructor() { + super(); + } + + validate(): void { + return; + } + + route(): string { + return Route.Approvals; + } + + type(): string { + return IsConnectionApprovedMsg.type(); + } +} export class ApproveConnectInterfaceMsg extends Message { public static type(): MessageType { @@ -74,7 +96,7 @@ export class GetChainMsg extends Message { super(); } - validate(): void { } + validate(): void {} route(): string { return Route.Chains; diff --git a/apps/extension/src/test/init.ts b/apps/extension/src/test/init.ts index aeb3ac7f03..62325d1472 100644 --- a/apps/extension/src/test/init.ts +++ b/apps/extension/src/test/init.ts @@ -43,7 +43,7 @@ const cryptoMemory = require("@namada/crypto").__wasm.memory; export class KVStoreMock implements KVStore { private storage: { [key: string]: T | null } = {}; - constructor(readonly _prefix: string) { } + constructor(readonly _prefix: string) {} get(key: string): Promise { return new Promise((resolve) => { @@ -141,7 +141,8 @@ export const init = async (): Promise<{ approvedOriginsStore, keyRingService, ledgerService, - vaultService + vaultService, + broadcaster ); const init = new Promise(async (resolve) => { diff --git a/packages/integrations/src/Namada.ts b/packages/integrations/src/Namada.ts index c8adf5446e..e6516524b2 100644 --- a/packages/integrations/src/Namada.ts +++ b/packages/integrations/src/Namada.ts @@ -14,7 +14,7 @@ import { BridgeProps, Integration } from "./types/Integration"; export default class Namada implements Integration { private _namada: WindowWithNamada["namada"] | undefined; - constructor(public readonly chain: Chain) { } + constructor(public readonly chain: Chain) {} public get instance(): INamada | undefined { return this._namada; @@ -35,6 +35,10 @@ export default class Namada implements Integration { await this._namada?.connect(chainId); } + public async isConnected(): Promise { + return await this._namada?.isConnected(); + } + public async getChain(): Promise { return await this._namada?.getChain(); } diff --git a/packages/types/src/events.ts b/packages/types/src/events.ts index b4dc006069..fe97f2283a 100644 --- a/packages/types/src/events.ts +++ b/packages/types/src/events.ts @@ -10,6 +10,7 @@ export enum Events { UpdatedStaking = "namada-updated-staking", ProposalsUpdated = "namada-proposals-updated", ExtensionLocked = "namada-extension-locked", + ConnectionRevoked = "namada-connection-revoked", } // Keplr extension events diff --git a/packages/types/src/namada.ts b/packages/types/src/namada.ts index e08f505f70..0a164d525c 100644 --- a/packages/types/src/namada.ts +++ b/packages/types/src/namada.ts @@ -33,6 +33,7 @@ export interface Namada { props: BalancesProps ): Promise<{ token: string; amount: string }[] | undefined>; connect(chainId?: string): Promise; + isConnected(): Promise; defaultAccount(chainId?: string): Promise; sign(props: SignArbitraryProps): Promise; verify(props: VerifyArbitraryProps): Promise; From 10594776218de811f8a9023d55a55f70e9ca7758 Mon Sep 17 00:00:00 2001 From: Eric Corson Date: Thu, 29 Feb 2024 11:08:20 +0900 Subject: [PATCH 3/5] fix: fix balances race condition (#641) --- apps/namada-interface/src/slices/accounts.ts | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/apps/namada-interface/src/slices/accounts.ts b/apps/namada-interface/src/slices/accounts.ts index 3500aa2367..162f8e2a59 100644 --- a/apps/namada-interface/src/slices/accounts.ts +++ b/apps/namada-interface/src/slices/accounts.ts @@ -191,8 +191,7 @@ const balancesAtom = (() => { currency: { address: nativeToken }, } = get(chainAtom); - // TODO: should we be throwing away old balances here? - const balances = get(base); + set(base, {}); accounts.forEach(async (account) => { const result = await namada.queryBalances(account.address, [ @@ -202,11 +201,7 @@ const balancesAtom = (() => { (acc, curr) => ({ ...acc, [curr.token]: new BigNumber(curr.amount) }), {} as Balance ); - balances[account.address] = balance; - set( - base, - { ...balances } // object clone ensures jotai realises object has changed - ); + set(base, { ...get(base), [account.address]: balance }); }); } ); From dad5b77806f71ace4386ce14106484693061790f Mon Sep 17 00:00:00 2001 From: Eric Corson Date: Thu, 29 Feb 2024 11:16:15 +0900 Subject: [PATCH 4/5] fix: add fetch effects and improve fetch behaviour (#641) - Add effects to handle accounts changed, chain changed, extension attached, and extension connected. Replaces fetches interspersed in App and AccountOverview - Refresh public keys on Tx completed instead of toast change - Use setter for fetching public keys to allow more control over when the fetch happens - Check for native token early in minimum gas price fetch --- .../App/AccountOverview/AccountOverview.tsx | 40 +++++++---- apps/namada-interface/src/App/App.tsx | 49 +++++-------- apps/namada-interface/src/App/fetchEffects.ts | 70 +++++++++++++++++++ .../extensionEvents/handlers/namada.ts | 17 ++++- .../src/services/extensionEvents/provider.tsx | 26 ++++++- apps/namada-interface/src/slices/fees.ts | 60 +++++++++++----- apps/namada-interface/src/slices/settings.ts | 10 +++ packages/hooks/src/index.ts | 3 +- .../hooks/src/useEffectSkipFirstRender.ts | 16 +++++ 9 files changed, 220 insertions(+), 71 deletions(-) create mode 100644 apps/namada-interface/src/App/fetchEffects.ts create mode 100644 packages/hooks/src/useEffectSkipFirstRender.ts diff --git a/apps/namada-interface/src/App/AccountOverview/AccountOverview.tsx b/apps/namada-interface/src/App/AccountOverview/AccountOverview.tsx index 69ab3f2d64..1361632eb0 100644 --- a/apps/namada-interface/src/App/AccountOverview/AccountOverview.tsx +++ b/apps/namada-interface/src/App/AccountOverview/AccountOverview.tsx @@ -7,10 +7,17 @@ import { useIntegrationConnection, useUntilIntegrationAttached, } from "@namada/integrations"; -import { Account, Chain, ExtensionKey, Extensions, TokenType, Tokens } from "@namada/types"; +import { + Account, + Chain, + ExtensionKey, + Extensions, + TokenType, + Tokens, +} from "@namada/types"; import { TopLevelRoute } from "App/types"; import { AccountsState, addAccounts, fetchBalances } from "slices/accounts"; -import { setIsConnected } from "slices/settings"; +import { namadaExtensionConnectedAtom, setIsConnected } from "slices/settings"; import { useAppDispatch, useAppSelector } from "store"; import { AccountOverviewContainer, @@ -25,9 +32,9 @@ import { } from "./AccountOverview.components"; import { DerivedAccounts } from "./DerivedAccounts"; -import { useAtomValue, useSetAtom } from "jotai"; -import { accountsAtom, balancesAtom } from "slices/accounts"; import BigNumber from "bignumber.js"; +import { useAtomValue, useSetAtom } from "jotai"; +import { balancesAtom } from "slices/accounts"; //TODO: move to utils when we have one const isEmptyObject = (object: Record): boolean => { @@ -35,6 +42,8 @@ const isEmptyObject = (object: Record): boolean => { }; export const AccountOverview = (): JSX.Element => { + const setNamadaExtensionConnected = useSetAtom(namadaExtensionConnectedAtom); + const navigate = useNavigate(); const dispatch = useAppDispatch(); const chain = useAppSelector((state) => state.chain.config); @@ -46,9 +55,6 @@ export const AccountOverview = (): JSX.Element => { metamask: false, }); - const refreshAccounts = useSetAtom(accountsAtom); - const refreshBalances = useSetAtom(balancesAtom); - const { derived } = useAppSelector((state) => state.accounts); const [integration, isConnectingToExtension, withConnection] = @@ -65,16 +71,14 @@ export const AccountOverview = (): JSX.Element => { const balances = useAtomValue(balancesAtom); const totalNativeBalance = Object.values(balances).reduce((acc, balance) => { - return acc.plus(balance[chain.currency.symbol as TokenType] || BigNumber(0)); + return acc.plus( + balance[chain.currency.symbol as TokenType] || BigNumber(0) + ); }, BigNumber(0)); const handleConnectExtension = async (): Promise => { withConnection( async () => { - // jotai - refreshAccounts(); - refreshBalances(); - const accounts = await integration?.accounts(); if (accounts) { dispatch(addAccounts(accounts as Account[])); @@ -86,12 +90,16 @@ export const AccountOverview = (): JSX.Element => { ...isExtensionConnected, [chain.extension.id]: true, }); + + setNamadaExtensionConnected(true); }, async () => { setIsExtensionConnected({ ...isExtensionConnected, [chain.extension.id]: false, }); + + setNamadaExtensionConnected(false); } ); }; @@ -108,8 +116,12 @@ export const AccountOverview = (): JSX.Element => { {!isEmptyObject(derived[chain.id]) && ( - {Tokens[chain.currency.symbol as TokenType].symbol} - {totalNativeBalance.toString()} + + {Tokens[chain.currency.symbol as TokenType].symbol} + + + {totalNativeBalance.toString()} + )} diff --git a/apps/namada-interface/src/App/App.tsx b/apps/namada-interface/src/App/App.tsx index b7091e38d5..014476dc57 100644 --- a/apps/namada-interface/src/App/App.tsx +++ b/apps/namada-interface/src/App/App.tsx @@ -1,7 +1,7 @@ /* eslint-disable max-len */ import { AnimatePresence } from "framer-motion"; import { createBrowserHistory } from "history"; -import { atom, useAtom, useAtomValue, useSetAtom } from "jotai"; +import { useAtomValue } from "jotai"; import { useEffect, useState } from "react"; import { PersistGate } from "redux-persist/integration/react"; import { ThemeProvider } from "styled-components"; @@ -23,13 +23,7 @@ import { Outlet } from "react-router-dom"; import { addAccounts, fetchBalances } from "slices/accounts"; import { setChain } from "slices/chain"; import { SettingsState } from "slices/settings"; -import { - persistor, - reduxStoreAtom, - store, - useAppDispatch, - useAppSelector, -} from "store"; +import { persistor, store, useAppDispatch, useAppSelector } from "store"; import { AppContainer, AppLoader, @@ -41,9 +35,14 @@ import { } from "./App.components"; import { TopNavigation } from "./TopNavigation"; -import { accountsAtom, balancesAtom } from "slices/accounts"; import { chainAtom } from "slices/chain"; -import { minimumGasPriceAtom } from "slices/fees"; + +import { + useOnAccountsChanged, + useOnChainChanged, + useOnNamadaExtensionAttached, + useOnNamadaExtensionConnected, +} from "./fetchEffects"; export const history = createBrowserHistory({ window }); @@ -65,11 +64,12 @@ export const AnimatedTransition = (props: { ); }; -// TODO: can be moved to slices/notifications once redux is removed -// Defining it there currently causes a unit test error related to redux-persist -const toastsAtom = atom((get) => get(reduxStoreAtom).notifications.toasts); - function App(): JSX.Element { + useOnNamadaExtensionAttached(); + useOnNamadaExtensionConnected(); + useOnAccountsChanged(); + useOnChainChanged(); + const dispatch = useAppDispatch(); const initialColorMode = loadColorMode(); const [colorMode, setColorMode] = useState(initialColorMode); @@ -79,10 +79,7 @@ function App(): JSX.Element { setColorMode((currentMode) => (currentMode === "dark" ? "light" : "dark")); }; - const [chain, refreshChain] = useAtom(chainAtom); - const refreshAccounts = useSetAtom(accountsAtom); - const refreshBalances = useSetAtom(balancesAtom); - const refreshMinimumGasPrice = useSetAtom(minimumGasPriceAtom); + const chain = useAtomValue(chainAtom); const { connectedChains } = useAppSelector( (state) => state.settings @@ -96,12 +93,9 @@ function App(): JSX.Element { const currentExtensionAttachStatus = extensionAttachStatus[chain.extension.id]; + // TODO: remove this effect once redux has been replaced by jotai useEffect(() => { const fetchAccounts = async (): Promise => { - // jotai - refreshAccounts(); - refreshBalances(); - const accounts = await integration?.accounts(); if (accounts) { dispatch(addAccounts(accounts as Account[])); @@ -113,16 +107,13 @@ function App(): JSX.Element { connectedChains.includes(chain.id) ) { fetchAccounts(); - refreshMinimumGasPrice(); } }, [chain]); + // TODO: remove this effect once redux has been replaced by jotai useEffect(() => { (async () => { if (currentExtensionAttachStatus === "attached") { - // jotai - refreshChain(); - const chain = await integration.getChain(); if (chain) { dispatch(setChain(chain)); @@ -131,12 +122,6 @@ function App(): JSX.Element { })(); }, [currentExtensionAttachStatus]); - const toasts = useAtomValue(toastsAtom); - useEffect(() => { - // TODO: this could be more conservative about how often it fetches balances - refreshBalances(); - }, [toasts]); - return ( diff --git a/apps/namada-interface/src/App/fetchEffects.ts b/apps/namada-interface/src/App/fetchEffects.ts new file mode 100644 index 0000000000..1f14d12f70 --- /dev/null +++ b/apps/namada-interface/src/App/fetchEffects.ts @@ -0,0 +1,70 @@ +import { useAtomValue, useSetAtom } from "jotai"; +import { loadable } from "jotai/utils"; + +import { useEffectSkipFirstRender } from "@namada/hooks"; +import { + Namada, + useIntegration, + useUntilIntegrationAttached, +} from "@namada/integrations"; +import { namadaExtensionConnectedAtom } from "slices/settings"; + +import { accountsAtom, balancesAtom } from "slices/accounts"; +import { chainAtom } from "slices/chain"; +import { isRevealPkNeededAtom, minimumGasPriceAtom } from "slices/fees"; + +export const useOnChainChanged = (): void => { + const chain = useAtomValue(chainAtom); + + const refreshMinimumGasPrice = useSetAtom(minimumGasPriceAtom); + const refreshPublicKeys = useSetAtom(isRevealPkNeededAtom); + + useEffectSkipFirstRender(() => { + refreshMinimumGasPrice(); + refreshPublicKeys(); + }, [chain]); +}; + +export const useOnNamadaExtensionAttached = (): void => { + const setNamadaExtensionConnected = useSetAtom(namadaExtensionConnectedAtom); + const chain = useAtomValue(chainAtom); // should always be namada + const { namada: attachStatus } = useUntilIntegrationAttached(chain); + const integration = useIntegration("namada") as Namada; + + useEffectSkipFirstRender(() => { + (async () => { + if (attachStatus === "attached") { + const connected = !!(await integration.isConnected()); + setNamadaExtensionConnected(connected); + } + })(); + }, [attachStatus]); +}; + +export const useOnNamadaExtensionConnected = (): void => { + const connected = useAtomValue(namadaExtensionConnectedAtom); + + const refreshChain = useSetAtom(chainAtom); + const refreshAccounts = useSetAtom(accountsAtom); + + useEffectSkipFirstRender(() => { + if (connected) { + refreshChain(); + refreshAccounts(); + } + }, [connected]); +}; + +export const useOnAccountsChanged = (): void => { + const accountsLoadable = useAtomValue(loadable(accountsAtom)); + + const refreshBalances = useSetAtom(balancesAtom); + const refreshPublicKeys = useSetAtom(isRevealPkNeededAtom); + + useEffectSkipFirstRender(() => { + if (accountsLoadable.state === "hasData") { + refreshBalances(); + refreshPublicKeys(); + } + }, [accountsLoadable]); +}; diff --git a/apps/namada-interface/src/services/extensionEvents/handlers/namada.ts b/apps/namada-interface/src/services/extensionEvents/handlers/namada.ts index d739ad8a27..893645a1d4 100644 --- a/apps/namada-interface/src/services/extensionEvents/handlers/namada.ts +++ b/apps/namada-interface/src/services/extensionEvents/handlers/namada.ts @@ -46,8 +46,9 @@ export const NamadaProposalsUpdatedHandler = }; export const NamadaUpdatedBalancesHandler = - (dispatch: Dispatch) => async () => { + (dispatch: Dispatch, refreshBalances: () => void) => async () => { dispatch(fetchBalances()); + refreshBalances(); }; export const NamadaUpdatedStakingHandler = @@ -67,7 +68,8 @@ export const NamadaTxStartedHandler = }; export const NamadaTxCompletedHandler = - (dispatch: Dispatch) => async (event: CustomEventInit) => { + (dispatch: Dispatch, refreshPublicKeys: () => void) => + async (event: CustomEventInit) => { const { msgId, txType, success, payload } = event.detail; if (!success) { console.warn(`${txType} failed:`, payload); @@ -80,4 +82,15 @@ export const NamadaTxCompletedHandler = error: payload || "", }) ); + refreshPublicKeys(); + }; + +export const NamadaConnectionRevokedHandler = + ( + integration: Namada, + setNamadaExtensionConnected: (connected: boolean) => void + ) => + async () => { + const connected = !!(await integration.isConnected()); + setNamadaExtensionConnected(connected); }; diff --git a/apps/namada-interface/src/services/extensionEvents/provider.tsx b/apps/namada-interface/src/services/extensionEvents/provider.tsx index 8e3bb6403a..37111546f8 100644 --- a/apps/namada-interface/src/services/extensionEvents/provider.tsx +++ b/apps/namada-interface/src/services/extensionEvents/provider.tsx @@ -10,6 +10,7 @@ import { MetamaskAccountChangedHandler, MetamaskBridgeTransferCompletedHandler, NamadaAccountChangedHandler, + NamadaConnectionRevokedHandler, NamadaNetworkChangedHandler, NamadaProposalsUpdatedHandler, NamadaTxCompletedHandler, @@ -19,8 +20,10 @@ import { } from "./handlers"; import { useSetAtom } from "jotai"; -import { accountsAtom } from "slices/accounts"; +import { accountsAtom, balancesAtom } from "slices/accounts"; import { chainAtom } from "slices/chain"; +import { isRevealPkNeededAtom } from "slices/fees"; +import { namadaExtensionConnectedAtom } from "slices/settings"; export const ExtensionEventsContext = createContext({}); @@ -32,6 +35,9 @@ export const ExtensionEventsProvider: React.FC = (props): JSX.Element => { const refreshAccounts = useSetAtom(accountsAtom); const refreshChain = useSetAtom(chainAtom); + const refreshBalances = useSetAtom(balancesAtom); + const refreshPublicKeys = useSetAtom(isRevealPkNeededAtom); + const setNamadaExtensionConnected = useSetAtom(namadaExtensionConnectedAtom); // Instantiate handlers: const namadaAccountChangedHandler = NamadaAccountChangedHandler( @@ -45,10 +51,20 @@ export const ExtensionEventsProvider: React.FC = (props): JSX.Element => { refreshChain ); const namadaTxStartedHandler = NamadaTxStartedHandler(dispatch); - const namadaTxCompletedHandler = NamadaTxCompletedHandler(dispatch); - const namadaUpdatedBalancesHandler = NamadaUpdatedBalancesHandler(dispatch); + const namadaTxCompletedHandler = NamadaTxCompletedHandler( + dispatch, + refreshPublicKeys + ); + const namadaUpdatedBalancesHandler = NamadaUpdatedBalancesHandler( + dispatch, + refreshBalances + ); const namadaUpdatedStakingHandler = NamadaUpdatedStakingHandler(dispatch); const namadaProposalsUpdatedHandler = NamadaProposalsUpdatedHandler(dispatch); + const namadaConnectionRevokedHandler = NamadaConnectionRevokedHandler( + namadaIntegration as Namada, + setNamadaExtensionConnected + ); // Keplr handlers const keplrAccountChangedHandler = KeplrAccountChangedHandler( @@ -73,6 +89,10 @@ export const ExtensionEventsProvider: React.FC = (props): JSX.Element => { useEventListenerOnce(Events.TxStarted, namadaTxStartedHandler); useEventListenerOnce(Events.TxCompleted, namadaTxCompletedHandler); useEventListenerOnce(Events.ProposalsUpdated, namadaProposalsUpdatedHandler); + useEventListenerOnce( + Events.ConnectionRevoked, + namadaConnectionRevokedHandler + ); useEventListenerOnce(KeplrEvents.AccountChanged, keplrAccountChangedHandler); useEventListenerOnce( MetamaskEvents.AccountChanged, diff --git a/apps/namada-interface/src/slices/fees.ts b/apps/namada-interface/src/slices/fees.ts index 86a566a972..b812a1c2d7 100644 --- a/apps/namada-interface/src/slices/fees.ts +++ b/apps/namada-interface/src/slices/fees.ts @@ -21,6 +21,10 @@ const minimumGasPriceAtom = (() => { const query = new Query(rpc); const promise = (async () => { + if (!nativeToken) { + throw new Error("Native token is undefined"); + } + const result = (await query.query_gas_costs()) as [string, string][]; const nativeTokenCost = result.find(([token]) => token === nativeToken); @@ -42,27 +46,45 @@ const minimumGasPriceAtom = (() => { ); })(); -const isRevealPkNeededAtom = atom(async (get) => { - const accounts = await get(accountsAtom); - const transparentAccounts = accounts.filter((account) => !account.isShielded); +const isRevealPkNeededAtom = (() => { + type RevealPkNeededMap = { [address: string]: boolean }; - const { rpc } = get(chainAtom); - const query = new Query(rpc); + const base = atom(new Promise(() => {})); - const map: Record = {}; - await Promise.all( - transparentAccounts.map(async ({ address }) => { - const publicKey = await query.query_public_key(address); - map[address] = publicKey; - }) - ); + return atom( + async (get) => { + const map = await get(base); - return (address: string): boolean => { - if (!(address in map)) { - throw new Error("address not found in public key map"); - } - return map[address] === null; - }; -}); + return (address: string): boolean => { + if (!(address in map)) { + throw new Error("address not found in public key map"); + } + return map[address]; + }; + }, + (get, set) => + set( + base, + (async (): Promise => { + const accounts = await get(accountsAtom); + const transparentAccounts = accounts.filter( + (account) => !account.isShielded + ); + + const { rpc } = get(chainAtom); + const query = new Query(rpc); + + const entries = await Promise.all( + transparentAccounts.map(async ({ address }) => { + const publicKey = await query.query_public_key(address); + return [address, !publicKey]; + }) + ); + + return Object.fromEntries(entries); + })() + ) + ); +})(); export { isRevealPkNeededAtom, minimumGasPriceAtom }; diff --git a/apps/namada-interface/src/slices/settings.ts b/apps/namada-interface/src/slices/settings.ts index d0aaed3875..090be08d0f 100644 --- a/apps/namada-interface/src/slices/settings.ts +++ b/apps/namada-interface/src/slices/settings.ts @@ -1,6 +1,8 @@ import { ChainKey } from "@namada/types"; import { createSlice, PayloadAction } from "@reduxjs/toolkit"; +import { atom } from "jotai"; + const SETTINGS_ACTIONS_BASE = "settings"; export type SettingsState = { @@ -28,3 +30,11 @@ const { actions, reducer } = settingsSlice; export const { setIsConnected } = actions; export default reducer; + +//////////////////////////////////////////////////////////////////////////////// +// JOTAI +//////////////////////////////////////////////////////////////////////////////// + +const namadaExtensionConnectedAtom = atom(false); + +export { namadaExtensionConnectedAtom }; diff --git a/packages/hooks/src/index.ts b/packages/hooks/src/index.ts index 365ffb025c..8a1f36dcd6 100644 --- a/packages/hooks/src/index.ts +++ b/packages/hooks/src/index.ts @@ -1,5 +1,6 @@ export * from "./useDebounce"; +export * from "./useEffectSkipFirstRender"; export * from "./useEvent"; -export * from "./useSanitizedParams"; export * from "./useSanitizedLocation"; +export * from "./useSanitizedParams"; export * from "./useUntil"; diff --git a/packages/hooks/src/useEffectSkipFirstRender.ts b/packages/hooks/src/useEffectSkipFirstRender.ts new file mode 100644 index 0000000000..a33eeebbcd --- /dev/null +++ b/packages/hooks/src/useEffectSkipFirstRender.ts @@ -0,0 +1,16 @@ +import { useEffect, useRef } from "react"; + +/** + * The same as useEffect, but does not run the effect on the first render. + */ +export const useEffectSkipFirstRender: typeof useEffect = (effect, deps) => { + const firstRender = useRef(true); + + useEffect(() => { + if (firstRender.current) { + firstRender.current = false; + } else { + effect(); + } + }, deps); +}; From 11757a6a83bdd305e7a48457aee115b9cd51bfb0 Mon Sep 17 00:00:00 2001 From: Eric Corson Date: Thu, 29 Feb 2024 11:31:55 +0900 Subject: [PATCH 5/5] fix: remove unneeded optional chaining (#641) --- packages/integrations/src/hooks/useIntegration.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/integrations/src/hooks/useIntegration.ts b/packages/integrations/src/hooks/useIntegration.ts index 7406252363..953f2c3482 100644 --- a/packages/integrations/src/hooks/useIntegration.ts +++ b/packages/integrations/src/hooks/useIntegration.ts @@ -66,10 +66,10 @@ export const useIntegration = ( export const useIntegrationConnection = ( chainKey: ChainKey ): [ - InstanceType, - boolean, - ExtensionConnection, - ] => { + InstanceType, + boolean, + ExtensionConnection, +] => { const integration = useIntegration(chainKey); const [isConnectingToExtension, setIsConnectingToExtension] = useState(false); @@ -77,8 +77,8 @@ export const useIntegrationConnection = ( async (onSuccess, onFail) => { setIsConnectingToExtension(true); try { - if (integration?.detect()) { - await integration?.connect(); + if (integration.detect()) { + await integration.connect(); await onSuccess(); } } catch (e) {