diff --git a/apps/extension/src/Approvals/Approvals.tsx b/apps/extension/src/Approvals/Approvals.tsx index 44dcaa866..9a839dd2b 100644 --- a/apps/extension/src/Approvals/Approvals.tsx +++ b/apps/extension/src/Approvals/Approvals.tsx @@ -7,6 +7,7 @@ import { AccountType, TxDetails } from "@namada/types"; import { AppHeader } from "App/Common/AppHeader"; import { TopLevelRoute } from "Approvals/types"; import { ApproveConnection } from "./ApproveConnection"; +import { ApproveDisconnection } from "./ApproveDisconnection"; import { ApproveSignArbitrary } from "./ApproveSignArbitrary"; import { ApproveSignTx } from "./ApproveSignTx"; import { ConfirmSignature } from "./ConfirmSignArbitrary"; @@ -65,6 +66,10 @@ export const Approvals: React.FC = () => { path={TopLevelRoute.ApproveConnection} element={} /> + } + /> { const requester = useRequester(); const params = useQuery(); const interfaceOrigin = params.get("interfaceOrigin"); - const interfaceTabId = params.get("interfaceTabId"); const handleResponse = async (allowConnection: boolean): Promise => { - if (interfaceTabId && interfaceOrigin) { + if (interfaceOrigin) { await requester.sendMessage( Ports.Background, - new ConnectInterfaceResponseMsg( - parseInt(interfaceTabId), - interfaceOrigin, - allowConnection - ) + new ConnectInterfaceResponseMsg(interfaceOrigin, allowConnection) ); await closeCurrentTab(); } diff --git a/apps/extension/src/Approvals/ApproveDisconnection.tsx b/apps/extension/src/Approvals/ApproveDisconnection.tsx new file mode 100644 index 000000000..0c8817723 --- /dev/null +++ b/apps/extension/src/Approvals/ApproveDisconnection.tsx @@ -0,0 +1,45 @@ +import { ActionButton, Alert, GapPatterns, Stack } from "@namada/components"; +import { PageHeader } from "App/Common"; +import { DisconnectInterfaceResponseMsg } from "background/approvals"; +import { useQuery } from "hooks"; +import { useRequester } from "hooks/useRequester"; +import { Ports } from "router"; +import { closeCurrentTab } from "utils"; + +export const ApproveDisconnection: React.FC = () => { + const requester = useRequester(); + const params = useQuery(); + const interfaceOrigin = params.get("interfaceOrigin"); + + const handleResponse = async (revokeConnection: boolean): Promise => { + if (interfaceOrigin) { + await requester.sendMessage( + Ports.Background, + new DisconnectInterfaceResponseMsg(interfaceOrigin, revokeConnection) + ); + await closeCurrentTab(); + } + }; + + return ( + + + + + Approve disconnect for {interfaceOrigin}? + + + handleResponse(true)}> + Approve + + handleResponse(false)} + > + Reject + + + + + ); +}; diff --git a/apps/extension/src/Approvals/types.ts b/apps/extension/src/Approvals/types.ts index 8578a1a28..5b4380cfb 100644 --- a/apps/extension/src/Approvals/types.ts +++ b/apps/extension/src/Approvals/types.ts @@ -5,6 +5,7 @@ export enum TopLevelRoute { // Connection approval ApproveConnection = "/approve-connection", + ApproveDisconnection = "/approve-disconnection", // Sign Tx approval ApproveSignTx = "/approve-sign-tx", diff --git a/apps/extension/src/background/approvals/handler.test.ts b/apps/extension/src/background/approvals/handler.test.ts index 408b65297..1b4903efa 100644 --- a/apps/extension/src/background/approvals/handler.test.ts +++ b/apps/extension/src/background/approvals/handler.test.ts @@ -12,6 +12,7 @@ import { Message } from "router"; import { getHandler } from "./handler"; import { ConnectInterfaceResponseMsg, + DisconnectInterfaceResponseMsg, RejectSignArbitraryMsg, RejectSignTxMsg, RevokeConnectionMsg, @@ -80,13 +81,19 @@ describe("approvals handler", () => { expect(service.approveConnection).toBeCalled(); const connectInterfaceResponseMsg = new ConnectInterfaceResponseMsg( - 0, "", true ); handler(env, connectInterfaceResponseMsg); expect(service.approveConnectionResponse).toBeCalled(); + const disconnectInterfaceResponseMsg = new DisconnectInterfaceResponseMsg( + "", + true + ); + handler(env, disconnectInterfaceResponseMsg); + expect(service.approveDisconnectionResponse).toBeCalled(); + const revokeConnectionMsg = new RevokeConnectionMsg(""); handler(env, revokeConnectionMsg); expect(service.revokeConnection).toBeCalled(); diff --git a/apps/extension/src/background/approvals/handler.ts b/apps/extension/src/background/approvals/handler.ts index 02153b0cd..f81f8b27b 100644 --- a/apps/extension/src/background/approvals/handler.ts +++ b/apps/extension/src/background/approvals/handler.ts @@ -1,5 +1,6 @@ import { ApproveConnectInterfaceMsg, + ApproveDisconnectInterfaceMsg, ApproveSignArbitraryMsg, ApproveSignTxMsg, IsConnectionApprovedMsg, @@ -7,6 +8,7 @@ import { import { Env, Handler, InternalHandler, Message } from "router"; import { ConnectInterfaceResponseMsg, + DisconnectInterfaceResponseMsg, QueryPendingTxBytesMsg, QuerySignArbitraryDataMsg, QueryTxDetailsMsg, @@ -37,6 +39,16 @@ export const getHandler: (service: ApprovalsService) => Handler = (service) => { env, msg as ConnectInterfaceResponseMsg ); + case ApproveDisconnectInterfaceMsg: + return handleApproveDisconnectInterfaceMsg(service)( + env, + msg as ApproveDisconnectInterfaceMsg + ); + case DisconnectInterfaceResponseMsg: + return handleDisconnectInterfaceResponseMsg(service)( + env, + msg as DisconnectInterfaceResponseMsg + ); case RevokeConnectionMsg: return handleRevokeConnectionMsg(service)( env, @@ -101,8 +113,8 @@ const handleIsConnectionApprovedMsg: ( const handleApproveConnectInterfaceMsg: ( service: ApprovalsService ) => InternalHandler = (service) => { - return async ({ senderTabId: interfaceTabId }, { origin }) => { - return await service.approveConnection(interfaceTabId, origin); + return async (_, { origin }) => { + return await service.approveConnection(origin); }; }; @@ -111,13 +123,35 @@ const handleConnectInterfaceResponseMsg: ( ) => InternalHandler = (service) => { return async ( { senderTabId: popupTabId }, - { interfaceTabId, interfaceOrigin, allowConnection } + { interfaceOrigin, allowConnection } ) => { return await service.approveConnectionResponse( - interfaceTabId, + popupTabId, + interfaceOrigin, + allowConnection + ); + }; +}; + +const handleApproveDisconnectInterfaceMsg: ( + service: ApprovalsService +) => InternalHandler = (service) => { + return async (_, { origin }) => { + return await service.approveDisconnection(origin); + }; +}; + +const handleDisconnectInterfaceResponseMsg: ( + service: ApprovalsService +) => InternalHandler = (service) => { + return async ( + { senderTabId: popupTabId }, + { interfaceOrigin, revokeConnection } + ) => { + return await service.approveDisconnectionResponse( + popupTabId, interfaceOrigin, - allowConnection, - popupTabId + revokeConnection ); }; }; diff --git a/apps/extension/src/background/approvals/init.ts b/apps/extension/src/background/approvals/init.ts index dcf3cc9be..f9065dca4 100644 --- a/apps/extension/src/background/approvals/init.ts +++ b/apps/extension/src/background/approvals/init.ts @@ -1,5 +1,6 @@ import { ApproveConnectInterfaceMsg, + ApproveDisconnectInterfaceMsg, ApproveSignArbitraryMsg, ApproveSignTxMsg, IsConnectionApprovedMsg, @@ -7,6 +8,7 @@ import { import { Router } from "router"; import { ConnectInterfaceResponseMsg, + DisconnectInterfaceResponseMsg, QueryPendingTxBytesMsg, QuerySignArbitraryDataMsg, QueryTxDetailsMsg, @@ -33,6 +35,8 @@ export function init(router: Router, service: ApprovalsService): void { router.registerMessage(IsConnectionApprovedMsg); router.registerMessage(ApproveConnectInterfaceMsg); router.registerMessage(ConnectInterfaceResponseMsg); + router.registerMessage(ApproveDisconnectInterfaceMsg); + router.registerMessage(DisconnectInterfaceResponseMsg); router.registerMessage(RevokeConnectionMsg); router.registerMessage(QueryTxDetailsMsg); router.registerMessage(QuerySignArbitraryDataMsg); diff --git a/apps/extension/src/background/approvals/messages.test.ts b/apps/extension/src/background/approvals/messages.test.ts index ab6cbe770..bf35570f1 100644 --- a/apps/extension/src/background/approvals/messages.test.ts +++ b/apps/extension/src/background/approvals/messages.test.ts @@ -63,7 +63,7 @@ describe("approvals messages", () => { }); test("valid ConnectInterfaceResponseMsg", () => { - const msg = new ConnectInterfaceResponseMsg(0, "interface", true); + const msg = new ConnectInterfaceResponseMsg("interface", true); expect(msg.type()).toBe(MessageType.ConnectInterfaceResponse); expect(msg.route()).toBe(ROUTE); @@ -71,21 +71,15 @@ describe("approvals messages", () => { }); test("invalid ConnectInterfaceResponseMsg", () => { - const msg = new ConnectInterfaceResponseMsg(0, "interface", true); - - (msg as any).interfaceTabId = undefined; + const msg = new ConnectInterfaceResponseMsg("interface", true); + (msg as any).interfaceOrigin = undefined; expect(() => msg.validate()).toThrow(); - const msg2 = new ConnectInterfaceResponseMsg(0, "interface", true); - (msg2 as any).interfaceOrigin = undefined; + const msg2 = new ConnectInterfaceResponseMsg("interface", true); + (msg2 as any).allowConnection = undefined; expect(() => msg2.validate()).toThrow(); - - const msg3 = new ConnectInterfaceResponseMsg(0, "interface", true); - (msg3 as any).allowConnection = undefined; - - expect(() => msg3.validate()).toThrow(); }); test("valid RevokeConnectionMsg", () => { diff --git a/apps/extension/src/background/approvals/messages.ts b/apps/extension/src/background/approvals/messages.ts index 2ee94399c..d4b8057e3 100644 --- a/apps/extension/src/background/approvals/messages.ts +++ b/apps/extension/src/background/approvals/messages.ts @@ -12,6 +12,7 @@ export enum MessageType { SubmitApprovedSignLedgerTx = "submit-approved-sign-ledger-tx", RejectSignArbitrary = "reject-sign-arbitrary", ConnectInterfaceResponse = "connect-interface-response", + DisconnectInterfaceResponse = "disconnect-interface-response", RevokeConnection = "revoke-connection", QueryTxDetails = "query-tx-details", QuerySignArbitraryData = "query-sign-arbitrary-data", @@ -144,7 +145,6 @@ export class ConnectInterfaceResponseMsg extends Message { } constructor( - public readonly interfaceTabId: number, public readonly interfaceOrigin: string, public readonly allowConnection: boolean ) { @@ -152,11 +152,7 @@ export class ConnectInterfaceResponseMsg extends Message { } validate(): void { - validateProps(this, [ - "interfaceTabId", - "interfaceOrigin", - "allowConnection", - ]); + validateProps(this, ["interfaceOrigin", "allowConnection"]); } route(): string { @@ -168,6 +164,31 @@ export class ConnectInterfaceResponseMsg extends Message { } } +export class DisconnectInterfaceResponseMsg extends Message { + public static type(): MessageType { + return MessageType.DisconnectInterfaceResponse; + } + + constructor( + public readonly interfaceOrigin: string, + public readonly revokeConnection: boolean + ) { + super(); + } + + validate(): void { + validateProps(this, ["interfaceOrigin", "revokeConnection"]); + } + + route(): string { + return ROUTE; + } + + type(): string { + return DisconnectInterfaceResponseMsg.type(); + } +} + export class RevokeConnectionMsg extends Message { public static type(): MessageType { return MessageType.RevokeConnection; diff --git a/apps/extension/src/background/approvals/service.test.ts b/apps/extension/src/background/approvals/service.test.ts index 783eb71cf..52247a1bf 100644 --- a/apps/extension/src/background/approvals/service.test.ts +++ b/apps/extension/src/background/approvals/service.test.ts @@ -1,6 +1,5 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { WrapperTxMsgValue } from "@namada/types"; -import { paramsToUrl } from "@namada/utils"; import { ChainsService } from "background/chains"; import { KeyRingService } from "background/keyring"; import { SdkService } from "background/sdk"; @@ -21,12 +20,16 @@ jest.mock("webextension-polyfill", () => ({ windows: { create: jest.fn().mockResolvedValue({ tabs: [{ id: 1 }] }), }, + tabs: { + onRemoved: { + addListener: jest.fn(), + }, + }, })); jest.mock("@namada/utils", () => { return { ...jest.requireActual("@namada/utils"), - paramsToUrl: jest.fn(), __esModule: true, }; }); @@ -260,22 +263,14 @@ describe("approvals service", () => { describe("approveConnection", () => { it("should approve connection if it's not already approved", async () => { - const url = "url-with-params"; - const interfaceTabId = 999; const interfaceOrigin = "origin"; const tabId = 1; - (paramsToUrl as any).mockImplementation(() => url); jest.spyOn(service, "isConnectionApproved").mockResolvedValue(false); - jest.spyOn(service as any, "_launchApprovalWindow").mockResolvedValue({ - tabs: [{ id: tabId }], - }); + jest.spyOn(service as any, "launchApprovalPopup"); service["resolverMap"] = {}; - const promise = service.approveConnection( - interfaceTabId, - interfaceOrigin - ); + const promise = service.approveConnection(interfaceOrigin); await new Promise((r) => setTimeout(() => { r(); @@ -283,76 +278,60 @@ describe("approvals service", () => { ); service["resolverMap"][tabId]?.resolve(true); - expect(paramsToUrl).toHaveBeenCalledWith("url#/approve-connection", { - interfaceTabId: interfaceTabId.toString(), - interfaceOrigin, - }); + expect(service["launchApprovalPopup"]).toHaveBeenCalledWith( + "/approve-connection", + { interfaceOrigin } + ); expect(service.isConnectionApproved).toHaveBeenCalledWith( interfaceOrigin ); - expect(service["_launchApprovalWindow"]).toHaveBeenCalledWith(url); await expect(promise).resolves.toBeDefined(); }); it("should not approve connection if it was already approved", async () => { - const url = "url-with-params"; - const interfaceTabId = 999; const interfaceOrigin = "origin"; - (paramsToUrl as any).mockImplementation(() => url); jest.spyOn(service, "isConnectionApproved").mockResolvedValue(true); await expect( - service.approveConnection(interfaceTabId, interfaceOrigin) + service.approveConnection(interfaceOrigin) ).resolves.toBeUndefined(); }); + }); - it("should throw an error when popupTabId is not found", async () => { - const url = "url-with-params"; - const interfaceTabId = 999; - const interfaceOrigin = "origin"; - - (paramsToUrl as any).mockImplementation(() => url); - jest.spyOn(service, "isConnectionApproved").mockResolvedValue(false); - jest.spyOn(service as any, "_launchApprovalWindow").mockResolvedValue({ - tabs: [], - }); - - await expect( - service.approveConnection(interfaceTabId, interfaceOrigin) - ).rejects.toBeDefined(); - }); - - it("should throw an error when popupTabId is found in resolverMap", async () => { - const url = "url-with-params"; - const interfaceTabId = 999; + describe("approveConnectionResponse", () => { + it("should approve connection response", async () => { const interfaceOrigin = "origin"; - const approvedOrigins = ["other-origin"]; - const tabId = 1; - + const popupTabId = 1; service["resolverMap"] = { - [tabId]: { + [popupTabId]: { resolve: jest.fn(), reject: jest.fn(), }, }; + jest.spyOn(localStorage, "addApprovedOrigin").mockResolvedValue(); - (paramsToUrl as any).mockImplementation(() => url); - jest - .spyOn(localStorage, "getApprovedOrigins") - .mockResolvedValue(approvedOrigins); - jest.spyOn(service as any, "_launchApprovalWindow").mockResolvedValue({ - tabs: [{ id: tabId }], - }); + await service.approveConnectionResponse( + popupTabId, + interfaceOrigin, + true + ); + + expect(service["resolverMap"][popupTabId].resolve).toHaveBeenCalled(); + expect(localStorage.addApprovedOrigin).toHaveBeenCalledWith( + interfaceOrigin + ); + }); + + it("should throw an error if resolvers are not found", async () => { + const interfaceOrigin = "origin"; + const popupTabId = 1; await expect( - service.approveConnection(interfaceTabId, interfaceOrigin) + service.approveConnectionResponse(popupTabId, interfaceOrigin, true) ).rejects.toBeDefined(); }); - }); - describe("approveConnectionResponse", () => { - it("should approve connection response", async () => { - const interfaceTabId = 999; + it("should reject the connection if allowConnection is set to false", async () => { const interfaceOrigin = "origin"; const popupTabId = 1; service["resolverMap"] = { @@ -361,38 +340,77 @@ describe("approvals service", () => { reject: jest.fn(), }, }; - jest.spyOn(localStorage, "addApprovedOrigin").mockResolvedValue(); await service.approveConnectionResponse( - interfaceTabId, + popupTabId, interfaceOrigin, - true, - popupTabId + false ); - expect(service["resolverMap"][popupTabId].resolve).toHaveBeenCalled(); - expect(localStorage.addApprovedOrigin).toHaveBeenCalledWith( + expect(service["resolverMap"][popupTabId].reject).toHaveBeenCalled(); + }); + }); + + describe("approveDisconnection", () => { + it("should approve disconnection if there is a connection already approved", async () => { + const interfaceOrigin = "origin"; + const tabId = 1; + + jest.spyOn(service, "isConnectionApproved").mockResolvedValue(true); + jest.spyOn(service as any, "launchApprovalPopup"); + service["resolverMap"] = {}; + + const promise = service.approveDisconnection(interfaceOrigin); + await new Promise((r) => + setTimeout(() => { + r(); + }) + ); + service["resolverMap"][tabId]?.resolve(true); + + expect((service as any).launchApprovalPopup).toHaveBeenCalledWith( + "/approve-disconnection", + { interfaceOrigin } + ); + expect(service.isConnectionApproved).toHaveBeenCalledWith( interfaceOrigin ); + await expect(promise).resolves.toBeDefined(); }); - it("should throw an error if resolvers are not found", async () => { - const interfaceTabId = 999; + it("should not approve disconnection if it is NOT already approved", async () => { const interfaceOrigin = "origin"; - const popupTabId = 1; + jest.spyOn(service, "isConnectionApproved").mockResolvedValue(false); await expect( - service.approveConnectionResponse( - interfaceTabId, - interfaceOrigin, - true, - popupTabId - ) - ).rejects.toBeDefined(); + service.approveDisconnection(interfaceOrigin) + ).resolves.toBeUndefined(); }); + }); - it("should reject the connection if allowConnection is set to false", async () => { - const interfaceTabId = 999; + describe("approveDisconnectionResponse", () => { + it("should approve disconnection response", async () => { + const interfaceOrigin = "origin"; + const popupTabId = 1; + service["resolverMap"] = { + [popupTabId]: { + resolve: jest.fn(), + reject: jest.fn(), + }, + }; + jest.spyOn(service, "revokeConnection").mockResolvedValue(); + + await service.approveDisconnectionResponse( + popupTabId, + interfaceOrigin, + true + ); + + expect(service["resolverMap"][popupTabId].resolve).toHaveBeenCalled(); + expect(service.revokeConnection).toHaveBeenCalledWith(interfaceOrigin); + }); + + it("should reject the connection if revokeConnection is set to false", async () => { const interfaceOrigin = "origin"; const popupTabId = 1; service["resolverMap"] = { @@ -403,10 +421,9 @@ describe("approvals service", () => { }; await service.approveConnectionResponse( - interfaceTabId, + popupTabId, interfaceOrigin, - false, - popupTabId + false ); expect(service["resolverMap"][popupTabId].reject).toHaveBeenCalled(); @@ -426,31 +443,100 @@ describe("approvals service", () => { }); }); + describe("createPopup", () => { + it("should create and return a new window", async () => { + const url = "url"; + const window = { tabs: [{ id: 1 }] }; + (webextensionPolyfill.windows.create as any).mockResolvedValue(window); + + await expect(service["createPopup"](url)).resolves.toBe(window); + expect(webextensionPolyfill.windows.create).toHaveBeenCalledWith( + expect.objectContaining({ url }) + ); + }); + }); + describe("getPopupTabId", () => { it("should return tab id", async () => { - (webextensionPolyfill.windows.create as any).mockResolvedValue({ - tabs: [{ id: 1 }], - }); + const window = { tabs: [{ id: 1 }] } as any; + expect(service["getPopupTabId"](window)).toBe(1); + }); - await expect((service as any).getPopupTabId("url")).resolves.toBe(1); + it("should throw an error if tabs are undefined", async () => { + const window = { tabs: undefined } as any; + expect(() => service["getPopupTabId"](window)).toThrow(); }); - it("should return undefined if tabs are undefined", async () => { - (webextensionPolyfill.windows.create as any).mockResolvedValue({}); + it("should throw an error if tabs are empty", async () => { + const window = { tabs: [] } as any; + expect(() => service["getPopupTabId"](window)).toThrow(); + }); - await expect( - (service as any).getPopupTabId("url") - ).resolves.toBeUndefined(); + it("should throw an error if the tab already exists on the resolverMap", async () => { + const popupTabId = 1; + const window = { tabs: [{ id: popupTabId }] } as any; + service["resolverMap"] = { + [popupTabId]: { + resolve: jest.fn(), + reject: jest.fn(), + }, + }; + + expect(() => service["getPopupTabId"](window)).toThrow(); }); + }); - it("should return undefined if tabs are empty", async () => { - (webextensionPolyfill.windows.create as any).mockResolvedValue({ - tabs: [], - }); + describe("launchApprovalPopup", () => { + it("should create a window with the given route and params saving the resolver on the resolverMap", async () => { + const route = "route"; + const params = { foo: "bar" }; + const popupTabId = 1; + const window = { tabs: [{ id: popupTabId }] }; - await expect( - (service as any).getPopupTabId("url") - ).resolves.toBeUndefined(); + jest + .spyOn(service, "createPopup") + .mockImplementationOnce(() => window); + + void service["launchApprovalPopup"](route, params); + + expect(service["createPopup"]).toHaveBeenCalledWith( + `url#${route}?foo=bar` + ); + await new Promise((r) => r()); + expect(service["resolverMap"][popupTabId]).toBeDefined(); + }); + }); + + describe("getResolver", () => { + it("should get the related tab id resolver from resolverMap", async () => { + const popupTabId = 1; + const resolver = { resolve: () => {}, reject: () => {} }; + service["resolverMap"] = { + [popupTabId]: resolver, + }; + + expect(service["getResolver"](popupTabId)).toBe(resolver); + }); + + it("should throw an error if there is no resolver for the tab id", async () => { + const popupTabId = 1; + service["resolverMap"] = { + [popupTabId]: { resolve: () => {}, reject: () => {} }, + }; + + expect(() => service["getResolver"](999)).toThrow(); + }); + }); + + describe("removeResolver", () => { + it("should remove related tab id resolver from resolverMap", async () => { + const popupTabId = 1; + service["resolverMap"] = { + [popupTabId]: { resolve: () => {}, reject: () => {} }, + }; + service["removeResolver"](popupTabId); + + expect(service["resolverMap"][popupTabId]).toBeUndefined(); }); }); diff --git a/apps/extension/src/background/approvals/service.ts b/apps/extension/src/background/approvals/service.ts index b255be9f6..ca167473d 100644 --- a/apps/extension/src/background/approvals/service.ts +++ b/apps/extension/src/background/approvals/service.ts @@ -7,6 +7,7 @@ import { SignArbitraryResponse, TxDetails } from "@namada/types"; import { paramsToUrl } from "@namada/utils"; import { ResponseSign } from "@zondax/ledger-namada"; +import { TopLevelRoute } from "Approvals/types"; import { ChainsService } from "background/chains"; import { KeyRingService } from "background/keyring"; import { SdkService } from "background/sdk"; @@ -16,14 +17,16 @@ import { LocalStorage } from "storage"; import { fromEncodedTx } from "utils"; import { EncodedTxData, PendingTx } from "./types"; +type Resolver = { + // TODO: there should be better typing for this + // eslint-disable-next-line + resolve: (result?: any) => void; + reject: (error?: unknown) => void; +}; + export class ApprovalsService { // holds promises which can be resolved with a message from a pop-up window - protected resolverMap: Record< - number, - // TODO: there should be better typing for this - // eslint-disable-next-line - { resolve: (result?: any) => void; reject: (error?: any) => void } - > = {}; + protected resolverMap: Record = {}; constructor( protected readonly txStore: KVStore, @@ -34,7 +37,15 @@ export class ApprovalsService { protected readonly vaultService: VaultService, protected readonly chainService: ChainsService, protected readonly broadcaster: ExtensionBroadcaster - ) {} + ) { + browser.tabs.onRemoved.addListener((tabId) => { + const resolver = this.getResolver(tabId); + if (resolver) { + resolver.reject(new Error("Window closed")); + this.removeResolver(tabId); + } + }); + } async approveSignTx( signer: string, @@ -56,23 +67,9 @@ export class ApprovalsService { await this.txStore.set(msgId, pendingTx); - const url = `${browser.runtime.getURL( - "approvals.html" - )}#/approve-sign-tx/${msgId}/${details.type}/${signer}`; - - const popupTabId = await this.getPopupTabId(url); - - if (!popupTabId) { - throw new Error("no popup tab ID"); - } - - if (popupTabId in this.resolverMap) { - throw new Error(`tab ID ${popupTabId} already exists in promise map`); - } - - return await new Promise((resolve, reject) => { - this.resolverMap[popupTabId] = { resolve, reject }; - }); + return this.launchApprovalPopup( + `${TopLevelRoute.ApproveSignTx}/${msgId}/${details.type}/${signer}` + ); } async approveSignArbitrary( @@ -82,26 +79,11 @@ export class ApprovalsService { const msgId = uuid(); await this.dataStore.set(msgId, data); - const baseUrl = `${browser.runtime.getURL( - "approvals.html" - )}#/approve-sign-arbitrary/${signer}`; - - const url = paramsToUrl(baseUrl, { - msgId, - }); - const popupTabId = await this.getPopupTabId(url); - if (!popupTabId) { - throw new Error("no popup tab ID"); - } - - if (popupTabId in this.resolverMap) { - throw new Error(`tab ID ${popupTabId} already exists in promise map`); - } - - return await new Promise((resolve, reject) => { - this.resolverMap[popupTabId] = { resolve, reject }; - }); + return this.launchApprovalPopup( + `${TopLevelRoute.ApproveSignArbitrary}/${signer}`, + { msgId } + ); } async submitSignTx( @@ -110,11 +92,7 @@ export class ApprovalsService { signer: string ): Promise { const pendingTx = (await this.txStore.get(msgId)) as PendingTx; - const resolvers = this.resolverMap[popupTabId]; - - if (!resolvers) { - throw new Error(`no resolvers found for tab ID ${popupTabId}`); - } + const resolvers = this.getResolver(popupTabId); if (!pendingTx) { throw new Error(`Signing data for ${msgId} not found!`); @@ -139,11 +117,7 @@ export class ApprovalsService { responseSign: ResponseSign[] ): Promise { const pendingTx = await this.txStore.get(msgId); - const resolvers = this.resolverMap[popupTabId]; - - if (!resolvers) { - throw new Error(`no resolvers found for tab ID ${popupTabId}`); - } + const resolvers = this.getResolver(popupTabId); if (!pendingTx || !pendingTx.txs) { throw new Error(`Transaction data for ${msgId} not found!`); @@ -173,11 +147,7 @@ export class ApprovalsService { signer: string ): Promise { const data = await this.dataStore.get(msgId); - const resolvers = this.resolverMap[popupTabId]; - - if (!resolvers) { - throw new Error(`no resolvers found for tab ID ${popupTabId}`); - } + const resolvers = this.getResolver(popupTabId); if (!data) { throw new Error(`Signing data for ${msgId} not found!`); @@ -195,11 +165,7 @@ export class ApprovalsService { } async rejectSignArbitrary(popupTabId: number, msgId: string): Promise { - const resolvers = this.resolverMap[popupTabId]; - - if (!resolvers) { - throw new Error(`no resolvers found for tab ID ${popupTabId}`); - } + const resolvers = this.getResolver(popupTabId); await this._clearPendingSignature(msgId); resolvers.reject(new Error("Sign arbitrary rejected")); @@ -207,10 +173,7 @@ export class ApprovalsService { // Remove pending transaction from storage async rejectSignTx(popupTabId: number, msgId: string): Promise { - const resolvers = this.resolverMap[popupTabId]; - if (!resolvers) { - throw new Error(`no resolvers found for tab ID ${popupTabId}`); - } + const resolvers = this.getResolver(popupTabId); await this._clearPendingTx(msgId); resolvers.reject(new Error("Sign Tx rejected")); @@ -223,34 +186,12 @@ export class ApprovalsService { return approvedOrigins.includes(interfaceOrigin); } - async approveConnection( - interfaceTabId: number, - interfaceOrigin: string - ): Promise { - const baseUrl = `${browser.runtime.getURL( - "approvals.html" - )}#/approve-connection`; - - const url = paramsToUrl(baseUrl, { - interfaceTabId: interfaceTabId.toString(), - interfaceOrigin, - }); - + async approveConnection(interfaceOrigin: string): Promise { const alreadyApproved = await this.isConnectionApproved(interfaceOrigin); if (!alreadyApproved) { - const popupTabId = await this.getPopupTabId(url); - - if (!popupTabId) { - throw new Error("no popup tab ID"); - } - - if (popupTabId in this.resolverMap) { - throw new Error(`tab ID ${popupTabId} already exists in promise map`); - } - - return new Promise((resolve, reject) => { - this.resolverMap[popupTabId] = { resolve, reject }; + return this.launchApprovalPopup(TopLevelRoute.ApproveConnection, { + interfaceOrigin, }); } @@ -259,15 +200,11 @@ export class ApprovalsService { } async approveConnectionResponse( - interfaceTabId: number, + popupTabId: number, interfaceOrigin: string, - allowConnection: boolean, - popupTabId: number + allowConnection: boolean ): Promise { - const resolvers = this.resolverMap[popupTabId]; - if (!resolvers) { - throw new Error(`no resolvers found for tab ID ${interfaceTabId}`); - } + const resolvers = this.getResolver(popupTabId); if (allowConnection) { try { @@ -281,6 +218,38 @@ export class ApprovalsService { } } + async approveDisconnection(interfaceOrigin: string): Promise { + const isConnected = await this.isConnectionApproved(interfaceOrigin); + + if (isConnected) { + return this.launchApprovalPopup(TopLevelRoute.ApproveDisconnection, { + interfaceOrigin, + }); + } + + // A resolved promise is implicitly returned here if the origin had + // previously been disconnected. + } + + async approveDisconnectionResponse( + popupTabId: number, + interfaceOrigin: string, + revokeConnection: boolean + ): Promise { + const resolvers = this.getResolver(popupTabId); + + if (revokeConnection) { + try { + await this.revokeConnection(interfaceOrigin); + } catch (e) { + resolvers.reject(e); + } + resolvers.resolve(); + } else { + resolvers.reject(); + } + } + async revokeConnection(originToRevoke: string): Promise { await this.localStorage.removeApprovedOrigin(originToRevoke); await this.broadcaster.revokeConnection(); @@ -329,7 +298,7 @@ export class ApprovalsService { return await this.dataStore.set(msgId, null); } - private _launchApprovalWindow = (url: string): Promise => { + private createPopup = (url: string): Promise => { return browser.windows.create({ url, width: 396, @@ -338,11 +307,54 @@ export class ApprovalsService { }); }; - private getPopupTabId = async (url: string): Promise => { - const window = await this._launchApprovalWindow(url); + private getPopupTabId = (window: browser.Windows.Window): number => { const firstTab = window.tabs?.[0]; const popupTabId = firstTab?.id; + if (!popupTabId) { + throw new Error("no popup tab ID"); + } + + if (popupTabId in this.resolverMap) { + throw new Error(`tab ID ${popupTabId} already exists in promise map`); + } + return popupTabId; }; + + private launchApprovalPopup = async ( + route: string, + params?: Record + ): Promise => { + const baseUrl = `${browser.runtime.getURL("approvals.html")}#${route}`; + const url = params ? paramsToUrl(baseUrl, params) : baseUrl; + + const window = await this.createPopup(url); + const popupTabId = this.getPopupTabId(window); + + return new Promise((resolve, reject) => { + this.resolverMap[popupTabId] = { + resolve: (args: T) => { + this.removeResolver(popupTabId); + return resolve(args); + }, + reject: (args: unknown) => { + this.removeResolver(popupTabId); + return reject(args); + }, + }; + }); + }; + + private getResolver = (popupTabId: number): Resolver => { + const resolvers = this.resolverMap[popupTabId]; + if (!resolvers) { + throw new Error(`no resolvers found for tab ID ${popupTabId}`); + } + return resolvers; + }; + + private removeResolver = (popupTabId: number): void => { + delete this.resolverMap[popupTabId]; + }; } diff --git a/apps/extension/src/provider/Namada.test.ts b/apps/extension/src/provider/Namada.test.ts index bd151b59d..9cc91aa25 100644 --- a/apps/extension/src/provider/Namada.test.ts +++ b/apps/extension/src/provider/Namada.test.ts @@ -21,6 +21,14 @@ jest.mock( ) ); +jest.mock("webextension-polyfill", () => ({ + tabs: { + onRemoved: { + addListener: jest.fn(), + }, + }, +})); + describe("Namada", () => { let namada: Namada; let vaultStorage: VaultStorage; diff --git a/apps/extension/src/provider/Namada.ts b/apps/extension/src/provider/Namada.ts index 498414ad1..6735fa520 100644 --- a/apps/extension/src/provider/Namada.ts +++ b/apps/extension/src/provider/Namada.ts @@ -9,10 +9,10 @@ import { } from "@namada/types"; import { MessageRequester, Ports } from "router"; -import { RevokeConnectionMsg } from "background/approvals"; import { toEncodedTx } from "utils"; import { ApproveConnectInterfaceMsg, + ApproveDisconnectInterfaceMsg, ApproveSignArbitraryMsg, ApproveSignTxMsg, CheckDurabilityMsg, @@ -40,7 +40,7 @@ export class Namada implements INamada { public async disconnect(): Promise { return await this.requester?.sendMessage( Ports.Background, - new RevokeConnectionMsg(location.origin) + new ApproveDisconnectInterfaceMsg(location.origin) ); } diff --git a/apps/extension/src/provider/messages.ts b/apps/extension/src/provider/messages.ts index 773cf0f9b..d53f3f7ee 100644 --- a/apps/extension/src/provider/messages.ts +++ b/apps/extension/src/provider/messages.ts @@ -20,6 +20,7 @@ enum MessageType { ApproveSignArbitrary = "approve-sign-arbitrary", IsConnectionApproved = "is-connection-approved", ApproveConnectInterface = "approve-connect-interface", + ApproveDisconnectInterface = "approve-disconnect-interface", QueryAccounts = "query-accounts", QueryDefaultAccount = "query-default-account", UpdateDefaultAccount = "update-default-account", @@ -134,6 +135,28 @@ export class ApproveConnectInterfaceMsg extends Message { } } +export class ApproveDisconnectInterfaceMsg extends Message { + public static type(): MessageType { + return MessageType.ApproveDisconnectInterface; + } + + constructor(public readonly originToRevoke: string) { + super(); + } + + validate(): void { + validateProps(this, ["originToRevoke"]); + } + + route(): string { + return Route.Approvals; + } + + type(): string { + return ApproveDisconnectInterfaceMsg.type(); + } +} + export class GetChainMsg extends Message { public static type(): MessageType { return MessageType.GetChain; diff --git a/apps/namadillo/src/App/AppRoutes.tsx b/apps/namadillo/src/App/AppRoutes.tsx index aea729966..6facd7bf2 100644 --- a/apps/namadillo/src/App/AppRoutes.tsx +++ b/apps/namadillo/src/App/AppRoutes.tsx @@ -13,7 +13,7 @@ import { RouteErrorBoundary } from "./Common/RouteErrorBoundary"; import { Governance } from "./Governance"; import { SettingsPanel } from "./Settings/SettingsPanel"; import { Staking } from "./Staking"; -import { SwitchAccountModal } from "./SwitchAccount/SwitchAccountModal"; +import { SwitchAccountPanel } from "./SwitchAccount/SwitchAccountPanel"; import GovernanceRoutes from "./Governance/routes"; import SettingsRoutes from "./Settings/routes"; @@ -52,7 +52,7 @@ export const MainRoutes = (): JSX.Element => { /> } + element={} errorElement={} /> { +export const SwitchAccountPanel = (): JSX.Element => { const { onCloseModal } = useModalCloseEvent(); const { data: defaultAccount } = useAtomValue(defaultAccountAtom); const { data } = useAtomValue(accountsAtom); const { mutateAsync: updateAccount } = useAtomValue(updateDefaultAccountAtom); return ( - +