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 (
-
+