From c8f8a4e6cb3f053001b161d17626fdda802944eb Mon Sep 17 00:00:00 2001
From: Bernardo Garces Chapero <bernardo.chapero@consensys.net>
Date: Thu, 15 Jun 2023 11:18:27 +0100
Subject: [PATCH] Refactor keyring to split bridge logic (#156)

---
 jest.config.js                                |   8 +-
 src/index.ts                                  |   4 +-
 src/ledger-bridge.ts                          |  54 ++
 src/ledger-iframe-bridge.test.ts              | 482 ++++++++++++++++
 src/ledger-iframe-bridge.ts                   | 312 +++++++++++
 ...keyring.test.ts => ledger-keyring.test.ts} | 345 +++++-------
 ...er-bridge-keyring.ts => ledger-keyring.ts} | 514 +++++-------------
 7 files changed, 1114 insertions(+), 605 deletions(-)
 create mode 100644 src/ledger-bridge.ts
 create mode 100644 src/ledger-iframe-bridge.test.ts
 create mode 100644 src/ledger-iframe-bridge.ts
 rename src/{ledger-bridge-keyring.test.ts => ledger-keyring.test.ts} (73%)
 rename src/{ledger-bridge-keyring.ts => ledger-keyring.ts} (58%)

diff --git a/jest.config.js b/jest.config.js
index d79019c5..178632c7 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -41,10 +41,10 @@ module.exports = {
   // An object that configures minimum threshold enforcement for coverage results
   coverageThreshold: {
     global: {
-      branches: 59.25,
-      functions: 81.94,
-      lines: 78.54,
-      statements: 78.49,
+      branches: 65.42,
+      functions: 88.57,
+      lines: 81.57,
+      statements: 81.49,
     },
   },
 
diff --git a/src/index.ts b/src/index.ts
index c75413da..b08ff492 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1 +1,3 @@
-export * from './ledger-bridge-keyring';
+export * from './ledger-keyring';
+export * from './ledger-iframe-bridge';
+export * from './ledger-bridge';
diff --git a/src/ledger-bridge.ts b/src/ledger-bridge.ts
new file mode 100644
index 00000000..bcee7afe
--- /dev/null
+++ b/src/ledger-bridge.ts
@@ -0,0 +1,54 @@
+import type LedgerHwAppEth from '@ledgerhq/hw-app-eth';
+
+export type GetPublicKeyParams = { hdPath: string };
+export type GetPublicKeyResponse = Awaited<
+  ReturnType<LedgerHwAppEth['getAddress']>
+> & {
+  chainCode: string;
+};
+
+export type LedgerSignTransactionParams = { hdPath: string; tx: string };
+export type LedgerSignTransactionResponse = Awaited<
+  ReturnType<LedgerHwAppEth['signTransaction']>
+>;
+
+export type LedgerSignMessageParams = { hdPath: string; message: string };
+export type LedgerSignMessageResponse = Awaited<
+  ReturnType<LedgerHwAppEth['signPersonalMessage']>
+>;
+
+export type LedgerSignTypedDataParams = {
+  hdPath: string;
+  domainSeparatorHex: string;
+  hashStructMessageHex: string;
+};
+export type LedgerSignTypedDataResponse = Awaited<
+  ReturnType<LedgerHwAppEth['signEIP712HashedMessage']>
+>;
+
+// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
+export interface LedgerBridge {
+  isDeviceConnected: boolean;
+
+  init(bridgeUrl: string): Promise<void>;
+
+  destroy(): Promise<void>;
+
+  attemptMakeApp(): Promise<boolean>;
+
+  updateTransportMethod(transportType: string): Promise<boolean>;
+
+  getPublicKey(params: GetPublicKeyParams): Promise<GetPublicKeyResponse>;
+
+  deviceSignTransaction(
+    params: LedgerSignTransactionParams,
+  ): Promise<LedgerSignTransactionResponse>;
+
+  deviceSignMessage(
+    params: LedgerSignMessageParams,
+  ): Promise<LedgerSignMessageResponse>;
+
+  deviceSignTypedData(
+    params: LedgerSignTypedDataParams,
+  ): Promise<LedgerSignTypedDataResponse>;
+}
diff --git a/src/ledger-iframe-bridge.test.ts b/src/ledger-iframe-bridge.test.ts
new file mode 100644
index 00000000..161eaf69
--- /dev/null
+++ b/src/ledger-iframe-bridge.test.ts
@@ -0,0 +1,482 @@
+import { hasProperty } from '@metamask/utils';
+
+import {
+  IFrameMessageAction,
+  LedgerIframeBridge,
+} from './ledger-iframe-bridge';
+import documentShim from '../test/document.shim';
+import windowShim from '../test/window.shim';
+
+global.document = documentShim;
+global.window = windowShim;
+
+// eslint-disable-next-line no-restricted-globals
+type HTMLIFrameElementShim = HTMLIFrameElement;
+// eslint-disable-next-line no-restricted-globals
+type WindowShim = Window;
+
+/**
+ * Checks if the iframe provided has a valid contentWindow
+ * and onload function.
+ *
+ * @param iframe - The iframe to check.
+ * @returns Returns true if the iframe is valid, false otherwise.
+ */
+function isIFrameValid(
+  iframe?: HTMLIFrameElementShim,
+): iframe is HTMLIFrameElementShim & { contentWindow: WindowShim } & {
+  onload: () => any;
+} {
+  return (
+    iframe !== undefined &&
+    hasProperty(iframe, 'contentWindow') &&
+    typeof iframe.onload === 'function' &&
+    hasProperty(iframe.contentWindow as WindowShim, 'postMessage')
+  );
+}
+
+/**
+ * Simulates the loading of an iframe by calling the onload function.
+ *
+ * @param iframe - The iframe to simulate the loading of.
+ * @returns Returns a promise that resolves when the onload function is called.
+ */
+async function simulateIFrameLoad(iframe?: HTMLIFrameElementShim) {
+  if (!isIFrameValid(iframe)) {
+    throw new Error('the iframe is not valid');
+  }
+  // we call manually the onload event to simulate the iframe loading
+  return await iframe.onload();
+}
+
+const LEDGER_IFRAME_ID = 'LEDGER-IFRAME';
+
+describe('LedgerIframeBridge', function () {
+  let bridge: LedgerIframeBridge;
+
+  /**
+   * Stubs the postMessage function of the keyring iframe.
+   *
+   * @param bridgeInstance - The bridge instance to stub.
+   * @param fn - The function to call when the postMessage function is called.
+   */
+  function stubKeyringIFramePostMessage(
+    bridgeInstance: LedgerIframeBridge,
+    fn: (message: any) => void,
+  ) {
+    if (!isIFrameValid(bridgeInstance.iframe)) {
+      throw new Error('the iframe is not valid');
+    }
+
+    jest
+      .spyOn(bridgeInstance.iframe.contentWindow, 'postMessage')
+      .mockImplementation(fn);
+  }
+
+  beforeEach(async function () {
+    bridge = new LedgerIframeBridge();
+    await bridge.init('bridgeUrl');
+    await simulateIFrameLoad(bridge.iframe);
+  });
+
+  afterEach(function () {
+    jest.clearAllMocks();
+  });
+
+  describe('init', function () {
+    it('sets up the listener and iframe', async function () {
+      bridge = new LedgerIframeBridge();
+
+      const addEventListenerSpy = jest.spyOn(global.window, 'addEventListener');
+
+      await bridge.init('bridgeUrl');
+
+      expect(addEventListenerSpy).toHaveBeenCalledTimes(1);
+      expect(bridge.iframeLoaded).toBe(false);
+
+      await simulateIFrameLoad(bridge.iframe);
+      expect(bridge.iframeLoaded).toBe(true);
+    });
+  });
+
+  describe('destroy', function () {
+    it('removes the message event listener', async function () {
+      const removeEventListenerSpy = jest.spyOn(
+        global.window,
+        'removeEventListener',
+      );
+
+      await bridge.destroy();
+
+      expect(removeEventListenerSpy).toHaveBeenCalledTimes(1);
+    });
+  });
+
+  describe('attemptMakeApp', function () {
+    it('sends and processes a successful ledger-make-app message', async function () {
+      stubKeyringIFramePostMessage(bridge, (message) => {
+        expect(message).toStrictEqual({
+          action: IFrameMessageAction.LedgerMakeApp,
+          messageId: 1,
+          target: LEDGER_IFRAME_ID,
+        });
+
+        bridge.messageCallbacks[message.messageId]?.({
+          action: IFrameMessageAction.LedgerMakeApp,
+          messageId: 1,
+          success: true,
+        });
+      });
+
+      const result = await bridge.attemptMakeApp();
+
+      expect(result).toBe(true);
+
+      // eslint-disable-next-line @typescript-eslint/unbound-method
+      expect(bridge.iframe?.contentWindow?.postMessage).toHaveBeenCalled();
+    });
+
+    it('throws an error when a ledger-make-app message is not successful', async function () {
+      const errorMessage = 'Ledger Error';
+
+      stubKeyringIFramePostMessage(bridge, (message) => {
+        expect(message).toStrictEqual({
+          action: IFrameMessageAction.LedgerMakeApp,
+          messageId: 1,
+          target: LEDGER_IFRAME_ID,
+        });
+
+        bridge.messageCallbacks[message.messageId]?.({
+          action: IFrameMessageAction.LedgerMakeApp,
+          messageId: 1,
+          success: false,
+          error: new Error(errorMessage),
+        });
+      });
+
+      await expect(bridge.attemptMakeApp()).rejects.toThrow(errorMessage);
+
+      // eslint-disable-next-line @typescript-eslint/unbound-method
+      expect(bridge.iframe?.contentWindow?.postMessage).toHaveBeenCalled();
+    });
+  });
+
+  describe('updateTransportMethod', function () {
+    it('sends and processes a successful ledger-update-transport message', async function () {
+      bridge.iframeLoaded = true;
+
+      const transportType = 'u2f';
+
+      stubKeyringIFramePostMessage(bridge, (message) => {
+        expect(message).toStrictEqual({
+          action: IFrameMessageAction.LedgerUpdateTransport,
+          messageId: 1,
+          target: LEDGER_IFRAME_ID,
+          params: { transportType },
+        });
+
+        bridge.messageCallbacks[message.messageId]?.({
+          action: IFrameMessageAction.LedgerUpdateTransport,
+          messageId: 1,
+          success: true,
+        });
+      });
+
+      const result = await bridge.updateTransportMethod(transportType);
+
+      expect(result).toBe(true);
+
+      // eslint-disable-next-line @typescript-eslint/unbound-method
+      expect(bridge.iframe?.contentWindow?.postMessage).toHaveBeenCalled();
+    });
+
+    it('throws an error when a ledger-update-transport message is not successful', async function () {
+      bridge.iframeLoaded = true;
+
+      const transportType = 'u2f';
+
+      stubKeyringIFramePostMessage(bridge, (message) => {
+        expect(message).toStrictEqual({
+          action: 'ledger-update-transport',
+          messageId: 1,
+          params: { transportType },
+          target: LEDGER_IFRAME_ID,
+        });
+
+        bridge.messageCallbacks[message.messageId]?.({
+          action: IFrameMessageAction.LedgerUpdateTransport,
+          messageId: 1,
+          success: false,
+        });
+      });
+
+      await expect(bridge.updateTransportMethod(transportType)).rejects.toThrow(
+        'Ledger transport could not be updated',
+      );
+
+      // eslint-disable-next-line @typescript-eslint/unbound-method
+      expect(bridge.iframe?.contentWindow?.postMessage).toHaveBeenCalled();
+    });
+  });
+
+  describe('getPublicKey', function () {
+    it('sends and processes a successful ledger-unlock message', async function () {
+      const payload = {
+        publicKey: '',
+        address: '',
+        chainCode: '',
+      };
+      const params = {
+        hdPath: "m/44'/60'/0'/0",
+      };
+
+      stubKeyringIFramePostMessage(bridge, (message) => {
+        expect(message).toStrictEqual({
+          action: IFrameMessageAction.LedgerUnlock,
+          params,
+          messageId: 1,
+          target: LEDGER_IFRAME_ID,
+        });
+
+        bridge.messageCallbacks[message.messageId]?.({
+          action: IFrameMessageAction.LedgerUnlock,
+          messageId: 1,
+          success: true,
+          payload,
+        });
+      });
+
+      const result = await bridge.getPublicKey(params);
+
+      expect(result).toBe(payload);
+
+      // eslint-disable-next-line @typescript-eslint/unbound-method
+      expect(bridge.iframe?.contentWindow?.postMessage).toHaveBeenCalled();
+    });
+
+    it('throws an error when a ledger-unlock message is not successful', async function () {
+      const errorMessage = 'Ledger Error';
+      const params = {
+        hdPath: "m/44'/60'/0'/0",
+      };
+
+      stubKeyringIFramePostMessage(bridge, (message) => {
+        expect(message).toStrictEqual({
+          action: IFrameMessageAction.LedgerUnlock,
+          messageId: 1,
+          target: LEDGER_IFRAME_ID,
+          params,
+        });
+
+        bridge.messageCallbacks[message.messageId]?.({
+          action: IFrameMessageAction.LedgerUnlock,
+          messageId: 1,
+          success: false,
+          payload: { error: new Error(errorMessage) },
+        });
+      });
+
+      await expect(bridge.getPublicKey(params)).rejects.toThrow(errorMessage);
+
+      // eslint-disable-next-line @typescript-eslint/unbound-method
+      expect(bridge.iframe?.contentWindow?.postMessage).toHaveBeenCalled();
+    });
+  });
+
+  describe('deviceSignTransaction', function () {
+    it('sends and processes a successful ledger-sign-transaction message', async function () {
+      const payload = {
+        v: '',
+        r: '',
+        s: '',
+      };
+      const params = {
+        hdPath: "m/44'/60'/0'/0",
+        tx: '',
+      };
+
+      stubKeyringIFramePostMessage(bridge, (message) => {
+        expect(message).toStrictEqual({
+          action: IFrameMessageAction.LedgerSignTransaction,
+          messageId: 1,
+          target: LEDGER_IFRAME_ID,
+          params,
+        });
+
+        bridge.messageCallbacks[message.messageId]?.({
+          action: IFrameMessageAction.LedgerSignTransaction,
+          messageId: 1,
+          success: true,
+          payload,
+        });
+      });
+
+      const result = await bridge.deviceSignTransaction(params);
+
+      expect(result).toBe(payload);
+
+      // eslint-disable-next-line @typescript-eslint/unbound-method
+      expect(bridge.iframe?.contentWindow?.postMessage).toHaveBeenCalled();
+    });
+
+    it('throws an error when a ledger-sign-transaction message is not successful', async function () {
+      const errorMessage = 'Ledger Error';
+      const params = { hdPath: "m/44'/60'/0'/0", tx: '' };
+
+      stubKeyringIFramePostMessage(bridge, (message) => {
+        expect(message).toStrictEqual({
+          action: IFrameMessageAction.LedgerSignTransaction,
+          messageId: 1,
+          target: LEDGER_IFRAME_ID,
+          params,
+        });
+
+        bridge.messageCallbacks[message.messageId]?.({
+          action: IFrameMessageAction.LedgerSignTransaction,
+          messageId: 1,
+          success: false,
+          payload: { error: new Error(errorMessage) },
+        });
+      });
+
+      await expect(bridge.deviceSignTransaction(params)).rejects.toThrow(
+        errorMessage,
+      );
+
+      // eslint-disable-next-line @typescript-eslint/unbound-method
+      expect(bridge.iframe?.contentWindow?.postMessage).toHaveBeenCalled();
+    });
+  });
+
+  describe('deviceSignMessage', function () {
+    it('sends and processes a successful ledger-sign-personal-message message', async function () {
+      const payload = {
+        v: 0,
+        r: '',
+        s: '',
+      };
+      const params = { hdPath: "m/44'/60'/0'/0", message: '' };
+
+      stubKeyringIFramePostMessage(bridge, (message) => {
+        expect(message).toStrictEqual({
+          action: IFrameMessageAction.LedgerSignPersonalMessage,
+          messageId: 1,
+          target: LEDGER_IFRAME_ID,
+          params,
+        });
+
+        bridge.messageCallbacks[message.messageId]?.({
+          action: IFrameMessageAction.LedgerSignPersonalMessage,
+          messageId: 1,
+          success: true,
+          payload,
+        });
+      });
+
+      const result = await bridge.deviceSignMessage(params);
+
+      expect(result).toBe(payload);
+
+      // eslint-disable-next-line @typescript-eslint/unbound-method
+      expect(bridge.iframe?.contentWindow?.postMessage).toHaveBeenCalled();
+    });
+
+    it('throws an error when a ledger-sign-personal-message message is not successful', async function () {
+      const errorMessage = 'Ledger Error';
+      const params = { hdPath: "m/44'/60'/0'/0", message: '' };
+
+      stubKeyringIFramePostMessage(bridge, (message) => {
+        expect(message).toStrictEqual({
+          action: IFrameMessageAction.LedgerSignPersonalMessage,
+          messageId: 1,
+          target: LEDGER_IFRAME_ID,
+          params,
+        });
+
+        bridge.messageCallbacks[message.messageId]?.({
+          action: IFrameMessageAction.LedgerSignPersonalMessage,
+          messageId: 1,
+          success: false,
+          payload: { error: new Error(errorMessage) },
+        });
+      });
+
+      await expect(bridge.deviceSignMessage(params)).rejects.toThrow(
+        errorMessage,
+      );
+
+      // eslint-disable-next-line @typescript-eslint/unbound-method
+      expect(bridge.iframe?.contentWindow?.postMessage).toHaveBeenCalled();
+    });
+  });
+
+  describe('deviceSignTypedData', function () {
+    it('sends and processes a successful ledger-sign-typed-data message', async function () {
+      const payload = {
+        v: 0,
+        r: '',
+        s: '',
+      };
+      const params = {
+        hdPath: "m/44'/60'/0'/0",
+        domainSeparatorHex: '',
+        hashStructMessageHex: '',
+      };
+
+      stubKeyringIFramePostMessage(bridge, (message) => {
+        expect(message).toStrictEqual({
+          action: IFrameMessageAction.LedgerSignTypedData,
+          messageId: 1,
+          target: LEDGER_IFRAME_ID,
+          params,
+        });
+
+        bridge.messageCallbacks[message.messageId]?.({
+          action: IFrameMessageAction.LedgerSignTypedData,
+          messageId: 1,
+          success: true,
+          payload,
+        });
+      });
+
+      const result = await bridge.deviceSignTypedData(params);
+
+      expect(result).toBe(payload);
+
+      // eslint-disable-next-line @typescript-eslint/unbound-method
+      expect(bridge.iframe?.contentWindow?.postMessage).toHaveBeenCalled();
+    });
+
+    it('throws an error when a ledger-sign-typed-data message is not successful', async function () {
+      const errorMessage = 'Ledger Error';
+      const params = {
+        hdPath: "m/44'/60'/0'/0",
+        domainSeparatorHex: '',
+        hashStructMessageHex: '',
+      };
+
+      stubKeyringIFramePostMessage(bridge, (message) => {
+        expect(message).toStrictEqual({
+          action: IFrameMessageAction.LedgerSignTypedData,
+          messageId: 1,
+          target: LEDGER_IFRAME_ID,
+          params,
+        });
+
+        bridge.messageCallbacks[message.messageId]?.({
+          action: IFrameMessageAction.LedgerSignTypedData,
+          messageId: 1,
+          success: false,
+          payload: { error: new Error(errorMessage) },
+        });
+      });
+
+      await expect(bridge.deviceSignTypedData(params)).rejects.toThrow(
+        errorMessage,
+      );
+
+      // eslint-disable-next-line @typescript-eslint/unbound-method
+      expect(bridge.iframe?.contentWindow?.postMessage).toHaveBeenCalled();
+    });
+  });
+});
diff --git a/src/ledger-iframe-bridge.ts b/src/ledger-iframe-bridge.ts
new file mode 100644
index 00000000..a5ffe5f8
--- /dev/null
+++ b/src/ledger-iframe-bridge.ts
@@ -0,0 +1,312 @@
+import {
+  GetPublicKeyParams,
+  GetPublicKeyResponse,
+  LedgerBridge,
+  LedgerSignMessageParams,
+  LedgerSignMessageResponse,
+  LedgerSignTransactionParams,
+  LedgerSignTransactionResponse,
+  LedgerSignTypedDataParams,
+  LedgerSignTypedDataResponse,
+} from './ledger-bridge';
+
+const LEDGER_IFRAME_ID = 'LEDGER-IFRAME';
+
+export enum IFrameMessageAction {
+  LedgerConnectionChange = 'ledger-connection-change',
+  LedgerUnlock = 'ledger-unlock',
+  LedgerMakeApp = 'ledger-make-app',
+  LedgerUpdateTransport = 'ledger-update-transport',
+  LedgerSignTransaction = 'ledger-sign-transaction',
+  LedgerSignPersonalMessage = 'ledger-sign-personal-message',
+  LedgerSignTypedData = 'ledger-sign-typed-data',
+}
+
+type IFrameMessageResponse<TAction extends IFrameMessageAction> = {
+  action: TAction;
+  messageId: number;
+} & (
+  | {
+      action: IFrameMessageAction.LedgerConnectionChange;
+      payload: { connected: boolean };
+    }
+  | ({
+      action: IFrameMessageAction.LedgerMakeApp;
+    } & ({ success: true } | { success: false; error?: unknown }))
+  | {
+      action: IFrameMessageAction.LedgerUpdateTransport;
+      success: boolean;
+    }
+  | ({
+      action: IFrameMessageAction.LedgerUnlock;
+    } & (
+      | { success: true; payload: GetPublicKeyResponse }
+      | { success: false; payload: { error: Error } }
+    ))
+  | ({
+      action: IFrameMessageAction.LedgerSignTransaction;
+    } & (
+      | { success: true; payload: LedgerSignTransactionResponse }
+      | { success: false; payload: { error: Error } }
+    ))
+  | ({
+      action:
+        | IFrameMessageAction.LedgerSignPersonalMessage
+        | IFrameMessageAction.LedgerSignTypedData;
+    } & (
+      | {
+          success: true;
+          payload: LedgerSignMessageResponse | LedgerSignTypedDataResponse;
+        }
+      | { success: false; payload: { error: Error } }
+    ))
+);
+
+type IFrameMessage<TAction extends IFrameMessageAction> = {
+  action: TAction;
+  params?: Readonly<Record<string, unknown>>;
+};
+
+type IFramePostMessage<TAction extends IFrameMessageAction> =
+  IFrameMessage<TAction> & {
+    messageId: number;
+    target: typeof LEDGER_IFRAME_ID;
+  };
+
+export class LedgerIframeBridge implements LedgerBridge {
+  iframe?: HTMLIFrameElement;
+
+  iframeLoaded = false;
+
+  eventListener?: (eventMessage: {
+    origin: string;
+    data: IFrameMessageResponse<IFrameMessageAction>;
+  }) => void;
+
+  isDeviceConnected = false;
+
+  currentMessageId = 0;
+
+  messageCallbacks: Record<
+    number,
+    (response: IFrameMessageResponse<IFrameMessageAction>) => void
+  > = {};
+
+  delayedPromise?: {
+    resolve: (value: boolean) => void;
+    reject: (error: unknown) => void;
+    transportType: string;
+  };
+
+  async init(bridgeUrl: string) {
+    this.#setupIframe(bridgeUrl);
+
+    this.eventListener = this.#eventListener.bind(this, bridgeUrl);
+
+    window.addEventListener('message', this.eventListener);
+  }
+
+  async destroy() {
+    if (this.eventListener) {
+      window.removeEventListener('message', this.eventListener);
+    }
+  }
+
+  async attemptMakeApp(): Promise<boolean> {
+    return new Promise((resolve, reject) => {
+      this.#sendMessage(
+        {
+          action: IFrameMessageAction.LedgerMakeApp,
+        },
+        (response) => {
+          if (response.success) {
+            resolve(true);
+          } else {
+            reject(response.error);
+          }
+        },
+      );
+    });
+  }
+
+  async updateTransportMethod(transportType: string): Promise<boolean> {
+    return new Promise((resolve, reject) => {
+      // If the iframe isn't loaded yet, let's store the desired transportType value and
+      // optimistically return a successful promise
+      if (!this.iframeLoaded) {
+        this.delayedPromise = {
+          resolve,
+          reject,
+          transportType,
+        };
+        return;
+      }
+
+      this.#sendMessage(
+        {
+          action: IFrameMessageAction.LedgerUpdateTransport,
+          params: { transportType },
+        },
+        ({ success }) => {
+          if (success) {
+            return resolve(true);
+          }
+          return reject(new Error('Ledger transport could not be updated'));
+        },
+      );
+    });
+  }
+
+  async getPublicKey(
+    params: GetPublicKeyParams,
+  ): Promise<GetPublicKeyResponse> {
+    return this.#deviceActionMessage(IFrameMessageAction.LedgerUnlock, params);
+  }
+
+  async deviceSignTransaction(
+    params: LedgerSignTransactionParams,
+  ): Promise<LedgerSignTransactionResponse> {
+    return this.#deviceActionMessage(
+      IFrameMessageAction.LedgerSignTransaction,
+      params,
+    );
+  }
+
+  async deviceSignMessage(
+    params: LedgerSignMessageParams,
+  ): Promise<LedgerSignMessageResponse> {
+    return this.#deviceActionMessage(
+      IFrameMessageAction.LedgerSignPersonalMessage,
+      params,
+    );
+  }
+
+  async deviceSignTypedData(
+    params: LedgerSignTypedDataParams,
+  ): Promise<LedgerSignTypedDataResponse> {
+    return this.#deviceActionMessage(
+      IFrameMessageAction.LedgerSignTypedData,
+      params,
+    );
+  }
+
+  async #deviceActionMessage(
+    action: IFrameMessageAction.LedgerUnlock,
+    params: GetPublicKeyParams,
+  ): Promise<GetPublicKeyResponse>;
+
+  async #deviceActionMessage(
+    action: IFrameMessageAction.LedgerSignTransaction,
+    params: LedgerSignTransactionParams,
+  ): Promise<LedgerSignTransactionResponse>;
+
+  async #deviceActionMessage(
+    action: IFrameMessageAction.LedgerSignPersonalMessage,
+    params: LedgerSignMessageParams,
+  ): Promise<LedgerSignMessageResponse>;
+
+  async #deviceActionMessage(
+    action: IFrameMessageAction.LedgerSignTypedData,
+    params: LedgerSignTypedDataParams,
+  ): Promise<LedgerSignTypedDataResponse>;
+
+  async #deviceActionMessage(
+    ...[action, params]:
+      | [IFrameMessageAction.LedgerUnlock, GetPublicKeyParams]
+      | [IFrameMessageAction.LedgerSignTransaction, LedgerSignTransactionParams]
+      | [IFrameMessageAction.LedgerSignPersonalMessage, LedgerSignMessageParams]
+      | [IFrameMessageAction.LedgerSignTypedData, LedgerSignTypedDataParams]
+  ) {
+    return new Promise((resolve, reject) => {
+      this.#sendMessage(
+        {
+          action,
+          params,
+        },
+        ({ success, payload }) => {
+          if (success) {
+            return resolve(payload);
+          }
+          return reject(payload.error);
+        },
+      );
+    });
+  }
+
+  #setupIframe(bridgeUrl: string) {
+    this.iframe = document.createElement('iframe');
+    this.iframe.src = bridgeUrl;
+    this.iframe.allow = `hid 'src'`;
+    this.iframe.onload = async () => {
+      // If the ledger live preference was set before the iframe is loaded,
+      // set it after the iframe has loaded
+      this.iframeLoaded = true;
+      if (this.delayedPromise) {
+        try {
+          const result = await this.updateTransportMethod(
+            this.delayedPromise.transportType,
+          );
+          this.delayedPromise.resolve(result);
+        } catch (error) {
+          this.delayedPromise.reject(error);
+        } finally {
+          delete this.delayedPromise;
+        }
+      }
+    };
+    document.head.appendChild(this.iframe);
+  }
+
+  #getOrigin(bridgeUrl: string) {
+    const tmp = bridgeUrl.split('/');
+    tmp.splice(-1, 1);
+    return tmp.join('/');
+  }
+
+  #eventListener(
+    bridgeUrl: string,
+    eventMessage: {
+      origin: string;
+      data: IFrameMessageResponse<IFrameMessageAction>;
+    },
+  ) {
+    if (eventMessage.origin !== this.#getOrigin(bridgeUrl)) {
+      return;
+    }
+
+    if (eventMessage.data) {
+      const messageCallback =
+        this.messageCallbacks[eventMessage.data.messageId];
+      if (messageCallback) {
+        messageCallback(eventMessage.data);
+      } else if (
+        eventMessage.data.action === IFrameMessageAction.LedgerConnectionChange
+      ) {
+        this.isDeviceConnected = eventMessage.data.payload.connected;
+      }
+    }
+  }
+
+  #sendMessage<TAction extends IFrameMessageAction>(
+    message: IFrameMessage<TAction>,
+    callback: (response: IFrameMessageResponse<TAction>) => void,
+  ) {
+    this.currentMessageId += 1;
+
+    const postMsg: IFramePostMessage<TAction> = {
+      ...message,
+      messageId: this.currentMessageId,
+      target: LEDGER_IFRAME_ID,
+    };
+
+    this.messageCallbacks[this.currentMessageId] = callback as (
+      response: IFrameMessageResponse<IFrameMessageAction>,
+    ) => void;
+
+    if (!this.iframeLoaded || !this.iframe || !this.iframe.contentWindow) {
+      throw new Error('The iframe is not loaded yet');
+    }
+
+    this.iframe.contentWindow.postMessage(postMsg, '*');
+  }
+}
diff --git a/src/ledger-bridge-keyring.test.ts b/src/ledger-keyring.test.ts
similarity index 73%
rename from src/ledger-bridge-keyring.test.ts
rename to src/ledger-keyring.test.ts
index 9fc07b53..4c15cd8f 100644
--- a/src/ledger-bridge-keyring.test.ts
+++ b/src/ledger-keyring.test.ts
@@ -1,22 +1,13 @@
 import { Common, Chain, Hardfork } from '@ethereumjs/common';
 import { TransactionFactory } from '@ethereumjs/tx';
-import { hasProperty } from '@metamask/utils';
 import sigUtil from 'eth-sig-util';
 import EthereumTx from 'ethereumjs-tx';
 import * as ethUtil from 'ethereumjs-util';
 import HDKey from 'hdkey';
 
-import { AccountDetails, LedgerBridgeKeyring } from './ledger-bridge-keyring';
-import documentShim from '../test/document.shim';
-import windowShim from '../test/window.shim';
-
-global.document = documentShim;
-global.window = windowShim;
-
-// eslint-disable-next-line no-restricted-globals
-type HTMLIFrameElementShim = HTMLIFrameElement;
-// eslint-disable-next-line no-restricted-globals
-type WindowShim = Window;
+import { LedgerBridge } from './ledger-bridge';
+import { LedgerIframeBridge } from './ledger-iframe-bridge';
+import { AccountDetails, LedgerKeyring } from './ledger-keyring';
 
 const fakeAccounts = [
   '0xF30952A1c534CDE7bC471380065726fa8686dfB3',
@@ -83,42 +74,9 @@ const fakeTypeTwoTx = TransactionFactory.fromTxData(
   { common: commonEIP1559, freeze: false },
 );
 
-/**
- * Checks if the iframe provided has a valid contentWindow
- * and onload function.
- *
- * @param iframe - The iframe to check.
- * @returns Returns true if the iframe is valid, false otherwise.
- */
-function isIFrameValid(
-  iframe?: HTMLIFrameElementShim,
-): iframe is HTMLIFrameElementShim & { contentWindow: WindowShim } & {
-  onload: () => any;
-} {
-  return (
-    iframe !== undefined &&
-    hasProperty(iframe, 'contentWindow') &&
-    typeof iframe.onload === 'function' &&
-    hasProperty(iframe.contentWindow as WindowShim, 'postMessage')
-  );
-}
-
-/**
- * Simulates the loading of an iframe by calling the onload function.
- *
- * @param iframe - The iframe to simulate the loading of.
- * @returns Returns a promise that resolves when the onload function is called.
- */
-async function simulateIFrameLoad(iframe?: HTMLIFrameElementShim) {
-  if (!isIFrameValid(iframe)) {
-    throw new Error('the iframe is not valid');
-  }
-  // we call manually the onload event to simulate the iframe loading
-  return await iframe.onload();
-}
-
-describe('LedgerBridgeKeyring', function () {
-  let keyring: LedgerBridgeKeyring;
+describe('LedgerKeyring', function () {
+  let keyring: LedgerKeyring;
+  let bridge: LedgerBridge;
 
   /**
    * Sets up the keyring to unlock one account.
@@ -134,30 +92,11 @@ describe('LedgerBridgeKeyring', function () {
       .mockResolvedValue(fakeAccounts[accountIndex] as string);
   }
 
-  /**
-   * Stubs the postMessage function of the keyring iframe.
-   *
-   * @param keyringInstance - The keyring instance to stub.
-   * @param fn - The function to call when the postMessage function is called.
-   */
-  function stubKeyringIFramePostMessage(
-    keyringInstance: LedgerBridgeKeyring,
-    fn: (message: any) => void,
-  ) {
-    if (!isIFrameValid(keyringInstance.iframe)) {
-      throw new Error('the iframe is not valid');
-    }
-
-    jest
-      .spyOn(keyringInstance.iframe.contentWindow, 'postMessage')
-      .mockImplementation(fn);
-  }
-
   beforeEach(async function () {
-    keyring = new LedgerBridgeKeyring();
+    bridge = new LedgerIframeBridge();
+    keyring = new LedgerKeyring({ bridge });
     keyring.hdk = fakeHdKey;
-
-    await simulateIFrameLoad(keyring.iframe);
+    await keyring.deserialize();
   });
 
   afterEach(function () {
@@ -166,25 +105,47 @@ describe('LedgerBridgeKeyring', function () {
 
   describe('Keyring.type', function () {
     it('is a class property that returns the type string.', function () {
-      const { type } = LedgerBridgeKeyring;
+      const { type } = LedgerKeyring;
       expect(typeof type).toBe('string');
     });
 
     it('returns the correct value', function () {
       const { type } = keyring;
-      const correct = LedgerBridgeKeyring.type;
+      const correct = LedgerKeyring.type;
       expect(type).toBe(correct);
     });
   });
 
   describe('constructor', function () {
     it('constructs', async function () {
-      const ledgerKeyring = new LedgerBridgeKeyring({ hdPath: `m/44'/60'/0'` });
+      const ledgerKeyring = new LedgerKeyring({
+        bridge: new LedgerIframeBridge(),
+      });
       expect(typeof ledgerKeyring).toBe('object');
 
       const accounts = await ledgerKeyring.getAccounts();
       expect(Array.isArray(accounts)).toBe(true);
     });
+
+    it('throws if a bridge is not provided', function () {
+      expect(
+        () =>
+          new LedgerKeyring({
+            bridge: undefined as unknown as LedgerBridge,
+          }),
+      ).toThrow('Bridge is a required dependency for the keyring');
+    });
+  });
+
+  describe('init', function () {
+    it('should call bridge init', async function () {
+      jest.spyOn(bridge, 'init').mockResolvedValue(undefined);
+
+      await keyring.init();
+
+      // eslint-disable-next-line @typescript-eslint/unbound-method
+      expect(bridge.init).toHaveBeenCalledTimes(1);
+    });
   });
 
   describe('serialize', function () {
@@ -277,53 +238,35 @@ describe('LedgerBridgeKeyring', function () {
     });
 
     it('should update hdk.publicKey if updateHdk is true', async function () {
-      const ledgerKeyring = new LedgerBridgeKeyring();
       // @ts-expect-error we want to bypass the set publicKey property set method
-      ledgerKeyring.hdk = { publicKey: 'ABC' };
-      await simulateIFrameLoad(ledgerKeyring.iframe);
-
-      stubKeyringIFramePostMessage(ledgerKeyring, (message) => {
-        ledgerKeyring.messageCallbacks[message.messageId]?.({
-          action: message.action,
-          messageId: message.messageId,
-          success: true,
-          payload: {
-            publicKey:
-              '04197ced33b63059074b90ddecb9400c45cbc86210a20317b539b8cae84e573342149c3384ae45f27db68e75823323e97e03504b73ecbc47f5922b9b8144345e5a',
-            chainCode:
-              'ba0fb16e01c463d1635ec36f5adeb93a838adcd1526656c55f828f1e34002a8b',
-            address: fakeAccounts[1],
-          },
-        });
+      keyring.hdk = { publicKey: 'ABC' };
+
+      jest.spyOn(bridge, 'getPublicKey').mockResolvedValue({
+        publicKey:
+          '04197ced33b63059074b90ddecb9400c45cbc86210a20317b539b8cae84e573342149c3384ae45f27db68e75823323e97e03504b73ecbc47f5922b9b8144345e5a',
+        chainCode:
+          'ba0fb16e01c463d1635ec36f5adeb93a838adcd1526656c55f828f1e34002a8b',
+        address: fakeAccounts[1],
       });
 
-      await ledgerKeyring.unlock(`m/44'/60'/0'/1`);
-      expect(ledgerKeyring.hdk.publicKey).not.toBe('ABC');
+      await keyring.unlock(`m/44'/60'/0'/1`);
+      expect(keyring.hdk.publicKey).not.toBe('ABC');
     });
 
     it('should not update hdk.publicKey if updateHdk is false', async function () {
-      const ledgerKeyring = new LedgerBridgeKeyring();
       // @ts-expect-error we want to bypass the publicKey property set method
-      ledgerKeyring.hdk = { publicKey: 'ABC' };
-      await simulateIFrameLoad(ledgerKeyring.iframe);
-
-      stubKeyringIFramePostMessage(ledgerKeyring, (message) => {
-        ledgerKeyring.messageCallbacks[message.messageId]?.({
-          action: message.action,
-          messageId: message.messageId,
-          success: true,
-          payload: {
-            publicKey:
-              '04197ced33b63059074b90ddecb9400c45cbc86210a20317b539b8cae84e573342149c3384ae45f27db68e75823323e97e03504b73ecbc47f5922b9b8144345e5a',
-            chainCode:
-              'ba0fb16e01c463d1635ec36f5adeb93a838adcd1526656c55f828f1e34002a8b',
-            address: fakeAccounts[1],
-          },
-        });
+      keyring.hdk = { publicKey: 'ABC' };
+
+      jest.spyOn(bridge, 'getPublicKey').mockResolvedValue({
+        publicKey:
+          '04197ced33b63059074b90ddecb9400c45cbc86210a20317b539b8cae84e573342149c3384ae45f27db68e75823323e97e03504b73ecbc47f5922b9b8144345e5a',
+        chainCode:
+          'ba0fb16e01c463d1635ec36f5adeb93a838adcd1526656c55f828f1e34002a8b',
+        address: fakeAccounts[1],
       });
 
-      await ledgerKeyring.unlock(`m/44'/60'/0'/1`, false);
-      expect(ledgerKeyring.hdk.publicKey).toBe('ABC');
+      await keyring.unlock(`m/44'/60'/0'/1`, false);
+      expect(keyring.hdk.publicKey).toBe('ABC');
     });
   });
 
@@ -536,29 +479,25 @@ describe('LedgerBridgeKeyring', function () {
     describe('using old versions of ethereumjs/tx', function () {
       it('should pass serialized transaction to ledger and return signed tx', async function () {
         await basicSetupToUnlockOneAccount();
-        stubKeyringIFramePostMessage(keyring, (message) => {
-          expect(message.params).toStrictEqual({
-            hdPath: "m/44'/60'/0'/0",
-            tx: fakeTx.serialize().toString('hex'),
+        jest
+          .spyOn(keyring.bridge, 'deviceSignTransaction')
+          .mockImplementation(async (params) => {
+            expect(params).toStrictEqual({
+              hdPath: "m/44'/60'/0'/0",
+              tx: fakeTx.serialize().toString('hex'),
+            });
+            return { v: '0x1', r: '0x0', s: '0x0' };
           });
 
-          keyring.messageCallbacks[message.messageId]?.({
-            ...message,
-            success: true,
-            payload: { v: '0x1', r: '0x0', s: '0x0' },
-          });
-        });
-
         jest.spyOn(fakeTx, 'verifySignature').mockReturnValue(true);
 
         const returnedTx = await keyring.signTransaction(
           fakeAccounts[0],
           fakeTx,
         );
-        expect(
-          // eslint-disable-next-line @typescript-eslint/unbound-method
-          keyring.iframe?.contentWindow?.postMessage,
-        ).toHaveBeenCalled();
+
+        // eslint-disable-next-line @typescript-eslint/unbound-method
+        expect(keyring.bridge.deviceSignTransaction).toHaveBeenCalled();
         expect(returnedTx).toHaveProperty('v');
         expect(returnedTx).toHaveProperty('r');
         expect(returnedTx).toHaveProperty('s');
@@ -592,20 +531,17 @@ describe('LedgerBridgeKeyring', function () {
           .spyOn(signedNewFakeTx, 'verifySignature')
           .mockImplementation(() => true);
 
-        stubKeyringIFramePostMessage(keyring, (message) => {
-          expect(message.params).toStrictEqual({
-            hdPath: "m/44'/60'/0'/0",
-            tx: ethUtil.rlp
-              .encode(newFakeTx.getMessageToSign(false))
-              .toString('hex'),
-          });
-
-          keyring.messageCallbacks[message.messageId]?.({
-            ...message,
-            success: true,
-            payload: expectedRSV,
+        jest
+          .spyOn(keyring.bridge, 'deviceSignTransaction')
+          .mockImplementation(async (params) => {
+            expect(params).toStrictEqual({
+              hdPath: "m/44'/60'/0'/0",
+              tx: ethUtil.rlp
+                .encode(newFakeTx.getMessageToSign(false))
+                .toString('hex'),
+            });
+            return expectedRSV;
           });
-        });
 
         const returnedTx = await keyring.signTransaction(
           fakeAccounts[0],
@@ -613,7 +549,7 @@ describe('LedgerBridgeKeyring', function () {
         );
 
         // eslint-disable-next-line @typescript-eslint/unbound-method
-        expect(keyring.iframe?.contentWindow?.postMessage).toHaveBeenCalled();
+        expect(keyring.bridge.deviceSignTransaction).toHaveBeenCalled();
         expect(returnedTx.toJSON()).toStrictEqual(signedNewFakeTx.toJSON());
       });
 
@@ -643,30 +579,23 @@ describe('LedgerBridgeKeyring', function () {
           .mockReturnValue(true);
 
         jest.spyOn(fakeTypeTwoTx, 'verifySignature').mockReturnValue(true);
-
-        stubKeyringIFramePostMessage(keyring, (message) => {
-          expect(message.params).toStrictEqual({
-            hdPath: "m/44'/60'/0'/0",
-            tx: fakeTypeTwoTx.getMessageToSign(false).toString('hex'),
-          });
-
-          keyring.messageCallbacks[message.messageId]?.({
-            ...message,
-            success: true,
-            payload: expectedRSV,
+        jest
+          .spyOn(keyring.bridge, 'deviceSignTransaction')
+          .mockImplementation(async (params) => {
+            expect(params).toStrictEqual({
+              hdPath: "m/44'/60'/0'/0",
+              tx: fakeTypeTwoTx.getMessageToSign(false).toString('hex'),
+            });
+            return expectedRSV;
           });
-        });
 
         const returnedTx = await keyring.signTransaction(
           fakeAccounts[0],
           fakeTypeTwoTx,
         );
 
-        expect(
-          // eslint-disable-next-line @typescript-eslint/unbound-method
-          keyring.iframe?.contentWindow?.postMessage,
-        ).toHaveBeenCalled();
-
+        // eslint-disable-next-line @typescript-eslint/unbound-method
+        expect(keyring.bridge.deviceSignTransaction).toHaveBeenCalled();
         expect(returnedTx.toJSON()).toStrictEqual(signedFakeTypeTwoTx.toJSON());
       });
     });
@@ -675,51 +604,48 @@ describe('LedgerBridgeKeyring', function () {
   describe('signPersonalMessage', function () {
     it('should call create a listener waiting for the iframe response', async function () {
       await basicSetupToUnlockOneAccount();
-
-      stubKeyringIFramePostMessage(keyring, (message) => {
-        expect(message.params).toStrictEqual({
-          hdPath: "m/44'/60'/0'/0",
-          message: 'some message',
-        });
-
-        keyring.messageCallbacks[message.messageId]?.({
-          ...message,
-          success: true,
-          payload: { v: 1, r: '0x0', s: '0x0' },
+      jest
+        .spyOn(keyring.bridge, 'deviceSignMessage')
+        .mockImplementation(async (params) => {
+          expect(params).toStrictEqual({
+            hdPath: "m/44'/60'/0'/0",
+            message: 'some message',
+          });
+          return { v: 1, r: '0x0', s: '0x0' };
         });
-      });
 
       jest
         .spyOn(sigUtil, 'recoverPersonalSignature')
         .mockReturnValue(fakeAccounts[0]);
+
       await keyring.signPersonalMessage(fakeAccounts[0], 'some message');
+
       // eslint-disable-next-line @typescript-eslint/unbound-method
-      expect(keyring.iframe?.contentWindow?.postMessage).toHaveBeenCalled();
+      expect(keyring.bridge.deviceSignMessage).toHaveBeenCalled();
     });
   });
 
   describe('signMessage', function () {
     it('should call create a listener waiting for the iframe response', async function () {
       await basicSetupToUnlockOneAccount();
-      stubKeyringIFramePostMessage(keyring, (message) => {
-        expect(message.params).toStrictEqual({
-          hdPath: "m/44'/60'/0'/0",
-          message: 'some message',
-        });
-
-        keyring.messageCallbacks[message.messageId]?.({
-          ...message,
-          success: true,
-          payload: { v: 1, r: '0x0', s: '0x0' },
+      jest
+        .spyOn(keyring.bridge, 'deviceSignMessage')
+        .mockImplementation(async (params) => {
+          expect(params).toStrictEqual({
+            hdPath: "m/44'/60'/0'/0",
+            message: 'some message',
+          });
+          return { v: 1, r: '0x0', s: '0x0' };
         });
-      });
 
       jest
         .spyOn(sigUtil, 'recoverPersonalSignature')
         .mockReturnValue(fakeAccounts[0]);
+
       await keyring.signMessage(fakeAccounts[0], 'some message');
+
       // eslint-disable-next-line @typescript-eslint/unbound-method
-      expect(keyring.iframe?.contentWindow?.postMessage).toHaveBeenCalled();
+      expect(keyring.bridge.deviceSignMessage).toHaveBeenCalled();
     });
   });
 
@@ -808,17 +734,13 @@ describe('LedgerBridgeKeyring', function () {
     });
 
     it('should resolve properly when called', async function () {
-      stubKeyringIFramePostMessage(keyring, (message) => {
-        keyring.messageCallbacks[message.messageId]?.({
-          ...message,
-          success: true,
-          payload: {
-            v: 27,
-            r: '72d4e38a0e582e09a620fd38e236fe687a1ec782206b56d576f579c026a7e5b9',
-            s: '46759735981cd0c3efb02d36df28bb2feedfec3d90e408efc93f45b894946e32',
-          },
-        });
-      });
+      jest
+        .spyOn(keyring.bridge, 'deviceSignTypedData')
+        .mockImplementation(async () => ({
+          v: 27,
+          r: '72d4e38a0e582e09a620fd38e236fe687a1ec782206b56d576f579c026a7e5b9',
+          s: '46759735981cd0c3efb02d36df28bb2feedfec3d90e408efc93f45b894946e32',
+        }));
 
       const result = await keyring.signTypedData(
         fakeAccounts[15],
@@ -831,17 +753,14 @@ describe('LedgerBridgeKeyring', function () {
     });
 
     it('should error when address does not match', async function () {
-      stubKeyringIFramePostMessage(keyring, (message) => {
-        keyring.messageCallbacks[message.messageId]?.({
-          ...message,
-          success: true,
-          payload: {
-            v: 28,
-            r: '72d4e38a0e582e09a620fd38e236fe687a1ec782206b56d576f579c026a7e5b9',
-            s: '46759735981cd0c3efb02d36df28bb2feedfec3d90e408efc93f45b894946e32',
-          },
-        });
-      });
+      jest
+        .spyOn(keyring.bridge, 'deviceSignTypedData')
+        // Changing v to 28 should cause a validation error
+        .mockImplementation(async () => ({
+          v: 28,
+          r: '72d4e38a0e582e09a620fd38e236fe687a1ec782206b56d576f579c026a7e5b9',
+          s: '46759735981cd0c3efb02d36df28bb2feedfec3d90e408efc93f45b894946e32',
+        }));
 
       await expect(
         keyring.signTypedData(fakeAccounts[15], fixtureData, options),
@@ -850,17 +769,13 @@ describe('LedgerBridgeKeyring', function () {
   });
 
   describe('destroy', function () {
-    it('should remove the message event listener', function () {
-      jest
-        .spyOn(global.window, 'removeEventListener')
-        .mockImplementation((type, listener) => {
-          expect(type).toBe('message');
-          expect(typeof listener).toBe('function');
-          return true;
-        });
-      keyring.destroy();
-      // eslint-disable-next-line no-restricted-globals
-      expect(global.window.removeEventListener).toHaveBeenCalled();
+    it('should call the destroy bridge method', async function () {
+      jest.spyOn(keyring.bridge, 'destroy').mockResolvedValue(undefined);
+
+      await keyring.destroy();
+
+      // eslint-disable-next-line @typescript-eslint/unbound-method
+      expect(bridge.destroy).toHaveBeenCalled();
     });
   });
 });
diff --git a/src/ledger-bridge-keyring.ts b/src/ledger-keyring.ts
similarity index 58%
rename from src/ledger-bridge-keyring.ts
rename to src/ledger-keyring.ts
index 2ed6eb43..bef198d3 100644
--- a/src/ledger-bridge-keyring.ts
+++ b/src/ledger-keyring.ts
@@ -1,6 +1,4 @@
 import { TransactionFactory, TxData, TypedTransaction } from '@ethereumjs/tx';
-import type LedgerHwAppEth from '@ledgerhq/hw-app-eth';
-import { hasProperty } from '@metamask/utils';
 // eslint-disable-next-line import/no-nodejs-modules
 import { Buffer } from 'buffer';
 import * as sigUtil from 'eth-sig-util';
@@ -10,6 +8,8 @@ import * as ethUtil from 'ethereumjs-util';
 import { EventEmitter } from 'events';
 import HDKey from 'hdkey';
 
+import { LedgerBridge } from './ledger-bridge';
+
 const pathBase = 'm';
 const hdPathString = `${pathBase}/44'/60'/0'`;
 const keyringType = 'Ledger Hardware';
@@ -18,8 +18,6 @@ const BRIDGE_URL = 'https://metamask.github.io/eth-ledger-bridge-keyring';
 
 const MAX_INDEX = 1000;
 
-const LEDGER_IFRAME_ID = 'LEDGER-IFRAME';
-
 enum NetworkApiUrls {
   Ropsten = 'http://api-ropsten.etherscan.io',
   Kovan = 'http://api-kovan.etherscan.io',
@@ -27,57 +25,10 @@ enum NetworkApiUrls {
   Mainnet = 'https://api.etherscan.io',
 }
 
-enum IFrameMessageAction {
-  LedgerConnectionChange = 'ledger-connection-change',
-  LedgerUnlock = 'ledger-unlock',
-  LedgerMakeApp = 'ledger-make-app',
-  LedgerUpdateTransport = 'ledger-update-transport',
-  LedgerSignTransaction = 'ledger-sign-transaction',
-  LedgerSignPersonalMessage = 'ledger-sign-personal-message',
-  LedgerSignTypedData = 'ledger-sign-typed-data',
-}
-
-type GetAddressPayload = Awaited<ReturnType<LedgerHwAppEth['getAddress']>> & {
-  chainCode: string;
-};
-
-type SignMessagePayload = Awaited<
-  ReturnType<LedgerHwAppEth['signEIP712HashedMessage']>
->;
-
 type SignTransactionPayload = Awaited<
-  ReturnType<LedgerHwAppEth['signTransaction']>
+  ReturnType<LedgerBridge['deviceSignTransaction']>
 >;
 
-type ConnectionChangedPayload = {
-  connected: boolean;
-};
-
-type IFrameMessage = {
-  action: IFrameMessageAction;
-  params?: Readonly<Record<string, unknown>>;
-};
-
-type IFramePostMessage = IFrameMessage & {
-  messageId: number;
-  target: typeof LEDGER_IFRAME_ID;
-};
-
-type IFrameMessageResponsePayload = { error?: Error } & (
-  | GetAddressPayload
-  | SignTransactionPayload
-  | SignMessagePayload
-  | ConnectionChangedPayload
-);
-
-export type IFrameMessageResponse = {
-  success: boolean;
-  action: IFrameMessageAction;
-  messageId: number;
-  payload: IFrameMessageResponsePayload;
-  error?: unknown;
-};
-
 export type AccountDetails = {
   index?: number;
   bip44?: boolean;
@@ -112,59 +63,7 @@ function isOldStyleEthereumjsTx(
   return 'getChainId' in tx && typeof tx.getChainId === 'function';
 }
 
-/**
- * Check if the given payload is a SignTransactionPayload.
- *
- * @param payload - IFrame message response payload to check.
- * @returns Returns `true` if payload is a SignTransactionPayload.
- */
-function isSignTransactionResponse(
-  payload: IFrameMessageResponsePayload,
-): payload is SignTransactionPayload {
-  return hasProperty(payload, 'v') && typeof payload.v === 'string';
-}
-
-/**
- * Check if the given payload is a SignMessagePayload.
- *
- * @param payload - IFrame message response payload to check.
- * @returns Returns `true` if payload is a SignMessagePayload.
- */
-function isSignMessageResponse(
-  payload: IFrameMessageResponsePayload,
-): payload is SignMessagePayload {
-  return hasProperty(payload, 'v') && typeof payload.v === 'number';
-}
-
-/**
- * Check if the given payload is a GetAddressPayload.
- *
- * @param payload - IFrame message response payload to check.
- * @returns Returns `true` if payload is a GetAddressPayload.
- */
-function isGetAddressMessageResponse(
-  payload: IFrameMessageResponsePayload,
-): payload is GetAddressPayload {
-  return (
-    hasProperty(payload, 'publicKey') && typeof payload.publicKey === 'string'
-  );
-}
-
-/**
- * Check if the given payload is a ConnectionChangedPayload.
- *
- * @param payload - IFrame message response payload to check.
- * @returns Returns `true` if payload is a ConnectionChangedPayload.
- */
-function isConnectionChangedResponse(
-  payload: IFrameMessageResponsePayload,
-): payload is ConnectionChangedPayload {
-  return (
-    hasProperty(payload, 'connected') && typeof payload.connected === 'boolean'
-  );
-}
-
-export class LedgerBridgeKeyring extends EventEmitter {
+export class LedgerKeyring extends EventEmitter {
   static type: string = keyringType;
 
   readonly type: string = keyringType;
@@ -189,34 +88,26 @@ export class LedgerBridgeKeyring extends EventEmitter {
 
   implementFullBIP44 = false;
 
-  iframeLoaded = false;
-
-  isDeviceConnected = false;
-
-  currentMessageId = 0;
-
-  messageCallbacks: Record<number, (response: IFrameMessageResponse) => void> =
-    {};
-
   bridgeUrl: string = BRIDGE_URL;
 
-  iframe?: HTMLIFrameElement;
+  bridge: LedgerBridge;
 
-  delayedPromise?: {
-    resolve: (value: unknown) => void;
-    reject: (error: unknown) => void;
-    transportType: string;
-  };
-
-  constructor(opts: Partial<LedgerBridgeKeyringOptions> = {}) {
+  constructor({ bridge }: { bridge: LedgerBridge }) {
     super();
 
-    // eslint-disable-next-line @typescript-eslint/no-floating-promises
-    this.deserialize(opts);
+    if (!bridge) {
+      throw new Error('Bridge is a required dependency for the keyring');
+    }
 
-    this.#setupIframe();
+    this.bridge = bridge;
+  }
+
+  async init() {
+    return this.bridge.init(this.bridgeUrl);
+  }
 
-    this.#setupListener();
+  async destroy() {
+    return this.bridge.destroy();
   }
 
   async serialize() {
@@ -287,7 +178,7 @@ export class LedgerBridgeKeyring extends EventEmitter {
   }
 
   isConnected() {
-    return this.isDeviceConnected;
+    return this.bridge.isDeviceConnected;
   }
 
   setAccountToUnlock(index: number | string) {
@@ -308,27 +199,22 @@ export class LedgerBridgeKeyring extends EventEmitter {
       return 'already unlocked';
     }
     const path = hdPath ? this.#toLedgerPath(hdPath) : this.hdPath;
-    return new Promise((resolve, reject) => {
-      this.#sendMessage(
-        {
-          action: IFrameMessageAction.LedgerUnlock,
-          params: {
-            hdPath: path,
-          },
-        },
-        ({ success, payload }) => {
-          if (success && isGetAddressMessageResponse(payload)) {
-            if (updateHdk) {
-              this.hdk.publicKey = Buffer.from(payload.publicKey, 'hex');
-              this.hdk.chainCode = Buffer.from(payload.chainCode, 'hex');
-            }
-            resolve(payload.address);
-          } else {
-            reject(payload.error ?? new Error('Unknown error'));
-          }
-        },
-      );
-    });
+
+    let payload;
+    try {
+      payload = await this.bridge.getPublicKey({
+        hdPath: path,
+      });
+    } catch (error) {
+      throw error instanceof Error ? error : new Error('Unknown error');
+    }
+
+    if (updateHdk && payload.chainCode) {
+      this.hdk.publicKey = Buffer.from(payload.publicKey, 'hex');
+      this.hdk.chainCode = Buffer.from(payload.chainCode, 'hex');
+    }
+
+    return payload.address;
   }
 
   async addAccounts(amount = 1): Promise<string[]> {
@@ -395,49 +281,11 @@ export class LedgerBridgeKeyring extends EventEmitter {
   }
 
   async attemptMakeApp() {
-    return new Promise((resolve, reject) => {
-      this.#sendMessage(
-        {
-          action: IFrameMessageAction.LedgerMakeApp,
-        },
-        ({ success, error }) => {
-          if (success) {
-            resolve(true);
-          } else {
-            reject(error);
-          }
-        },
-      );
-    });
+    return this.bridge.attemptMakeApp();
   }
 
   async updateTransportMethod(transportType: string) {
-    return new Promise((resolve, reject) => {
-      // If the iframe isn't loaded yet, let's store the desired transportType value and
-      // optimistically return a successful promise
-      if (!this.iframeLoaded) {
-        this.delayedPromise = {
-          resolve,
-          reject,
-          transportType,
-        };
-        return;
-      }
-
-      this.#sendMessage(
-        {
-          action: IFrameMessageAction.LedgerUpdateTransport,
-          params: { transportType },
-        },
-        ({ success }) => {
-          if (success) {
-            resolve(true);
-          } else {
-            reject(new Error('Ledger transport could not be updated'));
-          }
-        },
-      );
-    });
+    return this.bridge.updateTransportMethod(transportType);
   }
 
   // tx is an instance of the ethereumjs-transaction class.
@@ -514,41 +362,30 @@ export class LedgerBridgeKeyring extends EventEmitter {
       payload: SignTransactionPayload,
     ) => TypedTransaction | OldEthJsTransaction,
   ): Promise<TypedTransaction | OldEthJsTransaction> {
-    return new Promise((resolve, reject) => {
-      this.unlockAccountByAddress(address)
-        .then((hdPath) => {
-          this.#sendMessage(
-            {
-              action: IFrameMessageAction.LedgerSignTransaction,
-              params: {
-                tx: rawTxHex,
-                hdPath,
-              },
-            },
-            ({ success, payload }) => {
-              if (success && isSignTransactionResponse(payload)) {
-                const newOrMutatedTx = handleSigning(payload);
-                const valid = newOrMutatedTx.verifySignature();
-                if (valid) {
-                  resolve(newOrMutatedTx);
-                } else {
-                  reject(
-                    new Error('Ledger: The transaction signature is not valid'),
-                  );
-                }
-              } else {
-                reject(
-                  payload.error ??
-                    new Error(
-                      'Ledger: Unknown error while signing transaction',
-                    ),
-                );
-              }
-            },
-          );
-        })
-        .catch(reject);
-    });
+    const hdPath = await this.unlockAccountByAddress(address);
+
+    if (!hdPath) {
+      throw new Error('Ledger: Unknown error while signing transaction');
+    }
+
+    let payload;
+    try {
+      payload = await this.bridge.deviceSignTransaction({
+        tx: rawTxHex,
+        hdPath,
+      });
+    } catch (error) {
+      throw error instanceof Error
+        ? error
+        : new Error('Ledger: Unknown error while signing transaction');
+    }
+
+    const newOrMutatedTx = handleSigning(payload);
+    const valid = newOrMutatedTx.verifySignature();
+    if (valid) {
+      return newOrMutatedTx;
+    }
+    throw new Error('Ledger: The transaction signature is not valid');
   }
 
   async signMessage(withAccount: string, data: string) {
@@ -557,51 +394,41 @@ export class LedgerBridgeKeyring extends EventEmitter {
 
   // For personal_sign, we need to prefix the message:
   async signPersonalMessage(withAccount: string, message: string) {
-    return new Promise((resolve, reject) => {
-      this.unlockAccountByAddress(withAccount)
-        .then((hdPath) => {
-          this.#sendMessage(
-            {
-              action: IFrameMessageAction.LedgerSignPersonalMessage,
-              params: {
-                hdPath,
-                message: ethUtil.stripHexPrefix(message),
-              },
-            },
-            ({ success, payload }) => {
-              if (success && isSignMessageResponse(payload)) {
-                let recoveryId = parseInt(String(payload.v), 10).toString(16);
-                if (recoveryId.length < 2) {
-                  recoveryId = `0${recoveryId}`;
-                }
-                const signature = `0x${payload.r}${payload.s}${recoveryId}`;
-                const addressSignedWith = sigUtil.recoverPersonalSignature({
-                  data: message,
-                  // eslint-disable-next-line id-denylist
-                  sig: signature,
-                });
-                if (
-                  ethUtil.toChecksumAddress(addressSignedWith) !==
-                  ethUtil.toChecksumAddress(withAccount)
-                ) {
-                  reject(
-                    new Error(
-                      'Ledger: The signature doesnt match the right address',
-                    ),
-                  );
-                }
-                resolve(signature);
-              } else {
-                reject(
-                  payload.error ??
-                    new Error('Ledger: Unknown error while signing message'),
-                );
-              }
-            },
-          );
-        })
-        .catch(reject);
+    const hdPath = await this.unlockAccountByAddress(withAccount);
+
+    if (!hdPath) {
+      throw new Error('Ledger: Unknown error while signing message');
+    }
+
+    let payload;
+    try {
+      payload = await this.bridge.deviceSignMessage({
+        hdPath,
+        message: ethUtil.stripHexPrefix(message),
+      });
+    } catch (error) {
+      throw error instanceof Error
+        ? error
+        : new Error('Ledger: Unknown error while signing message');
+    }
+
+    let recoveryId = parseInt(String(payload.v), 10).toString(16);
+    if (recoveryId.length < 2) {
+      recoveryId = `0${recoveryId}`;
+    }
+    const signature = `0x${payload.r}${payload.s}${recoveryId}`;
+    const addressSignedWith = sigUtil.recoverPersonalSignature({
+      data: message,
+      // eslint-disable-next-line id-denylist
+      sig: signature,
     });
+    if (
+      ethUtil.toChecksumAddress(addressSignedWith) !==
+      ethUtil.toChecksumAddress(withAccount)
+    ) {
+      throw new Error('Ledger: The signature doesnt match the right address');
+    }
+    return signature;
   }
 
   async unlockAccountByAddress(address: string) {
@@ -657,47 +484,44 @@ export class LedgerBridgeKeyring extends EventEmitter {
     ).toString('hex');
 
     const hdPath = await this.unlockAccountByAddress(withAccount);
-    const { success, payload }: IFrameMessageResponse = await new Promise(
-      (resolve) => {
-        this.#sendMessage(
-          {
-            action: IFrameMessageAction.LedgerSignTypedData,
-            params: {
-              hdPath,
-              domainSeparatorHex,
-              hashStructMessageHex,
-            },
-          },
-          (result) => resolve(result),
-        );
-      },
-    );
 
-    if (success && isSignMessageResponse(payload)) {
-      let recoveryId = parseInt(String(payload.v), 10).toString(16);
-      if (recoveryId.length < 2) {
-        recoveryId = `0${recoveryId}`;
-      }
-      const signature = `0x${payload.r}${payload.s}${recoveryId}`;
-      // @ts-expect-error recoverTypedSignature_v4 is missing from
-      // @types/eth-sig-util.
-      // See: https://github.com/MetaMask/eth-sig-util/blob/v2.5.4/index.js#L464
-      const addressSignedWith = sigUtil.recoverTypedSignature_v4({
-        data,
-        // eslint-disable-next-line id-denylist
-        sig: signature,
+    if (!hdPath) {
+      throw new Error('Ledger: Unknown error while signing message');
+    }
+
+    let payload;
+    try {
+      payload = await this.bridge.deviceSignTypedData({
+        hdPath,
+        domainSeparatorHex,
+        hashStructMessageHex,
       });
-      if (
-        ethUtil.toChecksumAddress(addressSignedWith) !==
-        ethUtil.toChecksumAddress(withAccount)
-      ) {
-        throw new Error('Ledger: The signature doesnt match the right address');
-      }
-      return signature;
+    } catch (error) {
+      throw error instanceof Error
+        ? error
+        : new Error('Ledger: Unknown error while signing message');
     }
-    throw (
-      payload.error ?? new Error('Ledger: Unknown error while signing message')
-    );
+
+    let recoveryId = parseInt(String(payload.v), 10).toString(16);
+    if (recoveryId.length < 2) {
+      recoveryId = `0${recoveryId}`;
+    }
+    const signature = `0x${payload.r}${payload.s}${recoveryId}`;
+    // @ts-expect-error recoverTypedSignature_v4 is missing from
+    // @types/eth-sig-util.
+    // See: https://github.com/MetaMask/eth-sig-util/blob/v2.5.4/index.js#L464
+    const addressSignedWith = sigUtil.recoverTypedSignature_v4({
+      data,
+      // eslint-disable-next-line id-denylist
+      sig: signature,
+    });
+    if (
+      ethUtil.toChecksumAddress(addressSignedWith) !==
+      ethUtil.toChecksumAddress(withAccount)
+    ) {
+      throw new Error('Ledger: The signature doesnt match the right address');
+    }
+    return signature;
   }
 
   exportAccount() {
@@ -714,86 +538,6 @@ export class LedgerBridgeKeyring extends EventEmitter {
   }
 
   /* PRIVATE METHODS */
-
-  #setupIframe() {
-    this.iframe = document.createElement('iframe');
-    this.iframe.src = this.bridgeUrl;
-    this.iframe.allow = `hid 'src'`;
-    this.iframe.onload = async () => {
-      // If the ledger live preference was set before the iframe is loaded,
-      // set it after the iframe has loaded
-      this.iframeLoaded = true;
-      if (this.delayedPromise) {
-        try {
-          const result = await this.updateTransportMethod(
-            this.delayedPromise.transportType,
-          );
-          this.delayedPromise.resolve(result);
-        } catch (error) {
-          this.delayedPromise.reject(error);
-        } finally {
-          delete this.delayedPromise;
-        }
-      }
-    };
-    document.head.appendChild(this.iframe);
-  }
-
-  #getOrigin() {
-    const tmp = this.bridgeUrl.split('/');
-    tmp.splice(-1, 1);
-    return tmp.join('/');
-  }
-
-  #eventListener(params: { origin: string; data: IFrameMessageResponse }) {
-    if (params.origin !== this.#getOrigin()) {
-      return false;
-    }
-
-    if (params.data) {
-      const messageCallback = this.messageCallbacks[params.data.messageId];
-      if (messageCallback) {
-        messageCallback(params.data);
-      } else if (
-        params.data.action === IFrameMessageAction.LedgerConnectionChange &&
-        isConnectionChangedResponse(params.data.payload)
-      ) {
-        this.isDeviceConnected = params.data.payload.connected;
-      }
-    }
-
-    return undefined;
-  }
-
-  #sendMessage(
-    message: IFrameMessage,
-    callback: (response: IFrameMessageResponse) => void,
-  ) {
-    this.currentMessageId += 1;
-
-    const postMsg: IFramePostMessage = {
-      ...message,
-      messageId: this.currentMessageId,
-      target: LEDGER_IFRAME_ID,
-    };
-
-    this.messageCallbacks[this.currentMessageId] = callback;
-
-    if (!this.iframeLoaded || !this.iframe || !this.iframe.contentWindow) {
-      throw new Error('The iframe is not loaded yet');
-    }
-
-    this.iframe.contentWindow.postMessage(postMsg, '*');
-  }
-
-  #setupListener() {
-    window.addEventListener('message', this.#eventListener.bind(this));
-  }
-
-  destroy() {
-    window.removeEventListener('message', this.#eventListener.bind(this));
-  }
-
   async #getPage(increment: number) {
     this.page += increment;