Skip to content

Instantly share code, notes, and snippets.

@Wozacosta
Last active March 28, 2024 12:06
Show Gist options
  • Save Wozacosta/efca37c582b23bd2702307fc49aa620d to your computer and use it in GitHub Desktop.
Save Wozacosta/efca37c582b23bd2702307fc49aa620d to your computer and use it in GitHub Desktop.
dapp before after

Existing

Quick overview of the "Dapp browser"

What is a provider?

To find live apps using dapp browser, search, yearn and this yearn commit:

What is the "Dapp browser"

Consider it a normal live app, hosted on https://dapp-browser.apps.ledger.com

Here's the entrypoint element:

  1. DappBrowserPage (pages/index.tsx) renders <DAPPBrowser>
  2. Processes query params passed to https://dapp-browser.apps.ledger.com like accountId, networks and dappUrl
  3. Renders DAPPBrowser (src/DAPPBrowser/index.tsx) and passed it some arguments like dappUrl and initialAccountId
  4. DappBrowser sets up send/receive message listeners
  5. DappBrowser fetches all accounts filtered by permissions
  6. DappBrwoser sets up wallet api like this : ledgerAPI = new LedgerLivePlarformSDK(new WindowMessageTransport());
  7. logic to set selectedAccount, that depends on initialAccountId, storedAccount (in localstorage) and defaults to filteredAccounts[0]
  8. has UI logic to select accounts with <AccountRequest> (inner top bar)
  9. constructs dappUrl, then, if connected && accounts.length > 0 : displays <DappIframe />, an iframe that gets passed src=dappUrl to it

Current task requirements

https://ledgerhq.atlassian.net/wiki/spaces/WAL/pages/4524310529/Ledger+Connectivity+-+02+24+-+Steering+Report

  1. Dapp Browser Refactoring
    1. Removing a nested layer (Navigation within dapps and simplification of CORS handling).
    2. Moving the DappBrowserLogic up into the Web3AppWebview that handles all live apps inside LL.
    3. Remove the need for the Iframe Provider injection by dapp developers
    4. Deprecation of the DappBrowser repo.
    5. Support of EIP-6963
    6. This is a first step in the direction of the Web3Browser on mobile. We will still have to solve the issue of handling dapps that don’t have a manifest.

what this PR introduces

  • useWebView (src/renderer/components/Web3AppWebview/WalletAPIWebview.tsx)
  const { onDappMessage } = useDappLogic({ // NEW
    manifest,
    accounts,
    uiHook,
    postMessage: webviewHook.postMessage,
    tracking,
  });

  const handleMessage = useCallback(
    (event: Electron.IpcMessageEvent) => {
      if (event.channel === "webviewToParent") {
        onMessage(event.args[0]);
      }
      if (event.channel === "dappToParent") { // NEW
        onDappMessage(event.args[0]);
      }
    },
    [onDappMessage, onMessage],
  );
  • The webview created in useWebView now has a different setting:
BEFORE: webpreferences = "nativeWindowOpen=no"
NOW: webpreferences="contextIsolation=no, nativeWindowOpen=no"

More info on electron's context isolation

tl;dr this allows us to set stuff globally on window that will be available by the webview.

  • Thus, now in the preloader (src/webviewPreloader/index.ts) , we don't have to do :
contextBridge.exposeInMainWorld("ElectronWebview", {
postMessage: (message: unknown) => ipcRenderer.sendToHost("webviewToParent", message),
}

We simply do :

window.ElectronWebview = {
  postMessage: (message: unknown) => ipcRenderer.sendToHost("webviewToParent", message),
  postDappMessage: (message: unknown) => ipcRenderer.sendToHost("dappToParent", message),
}

In that file, we also add an event listener on the load event, there it will :

// set a provider
window.ethereum = new LedgerLiveEthereumProvider();

// announce to the live app that a new provider is available
// this is the core of EIP-6963 https://medium.com/blockapex/simplifying-eip-6963-5e9c78d3e10d

    window.dispatchEvent(
      new CustomEvent("eip6963:announceProvider", {
        detail: Object.freeze({ info, provider }),
      }),
    );

That LedgerLiveEthereumProvider provider lives next to the preloader file (in src/webviewPreloader/LedgerLiveEthereumProvider.ts)

It's an event emitter that implements EIP-1193

We set up event handlers

    this.completers = : {
        [id: string]: PromiseCompleter<any, any>;
    } = {} // Store pending promises
    this.eventSource = window,
    this.eventTarget = window.ElectronWebview,
    this.eventSource.addEventListener("message", this.handleEventSourceMessage);
/** **/
  private handleEventSourceMessage = (event) => {
    const data = event.data;
/** **/

    try {
      const message = JSON.parse(data);

      // If the message has an ID, it is possibly a response message
      if (typeof message.id !== "undefined" && message.id !== null) {
        const completer = this.completers["" + message.id];
        // True if we haven't timed out and this is a response to a message we sent.
        if (completer) {
          // Handle pending promise
        }
      }

      // If the method is a request from the parent window, it is likely a subscription.
      if ("method" in message) {
        switch (message.method) {
          case "notification":
            this.emitNotification(message.params);
            break;
          case "connect":
            this.emitConnect();
            break;
          case "close":
            this.emitClose(message.params[0], message.params[1]);
            break;
          case "chainChanged":
            this.emitChainChanged(message.params[0]);
            break;
          case "networkChanged":
            this.emitNetworkChanged(message.params[0]);
            break;
          case "accountsChanged":
            this.emitAccountsChanged(message.params[0]);
            break;
        }
      }
    } 
  };

We also implement the request method (and its two deprecated siblings, send and sendAsync)

  request: (args: RequestArguments) => Promise<unknown>;
  /** @deprecated */
  send(...args: unknown[]): unknown;
  /** @deprecated */
  sendAsync(request: Object, callback: Function): void;

They all use the postDappMessage => ipcRenderer.sendToHost("dappToParent", message) method that was defined in the preloader to send requests to Ledger Live.

  • Let's backtrack back to the beginning and useWebView, remember that we now have this:
  const { onDappMessage } = useDappLogic({ // NEW
    manifest,
    accounts,
    uiHook,
    postMessage: webviewHook.postMessage,
    tracking,
  });

  const handleMessage = useCallback(
    (event: Electron.IpcMessageEvent) => {
      if (event.channel === "webviewToParent") {
        onMessage(event.args[0]);
      }
      if (event.channel === "dappToParent") { // NEW, event that's triggered by LedgerLiveEthereumProvider.request
        onDappMessage(event.args[0]); // What does it do 🧐!? see below
      }
    },
    [onDappMessage, onMessage],
  );

So, LedgerLiveEthereumProvider.request, will trigger a call to onDappMessage, this is a function that's returned by useDappLogic

useDappLogic

in libs/ledger-live-common/src/wallet-api/useDappLogic.ts

export function useDappLogic({
  manifest,
  accounts,
  postMessage, // hook for Ledger Live -> webview comms
  uiHook,
}: {
/** **/

}

There, we:

  • extracts currentNetwork from manifest.dapp?.networks[0]
  • set the currentAccount using useDappAccountLogic
  • use postMessage to inform dapp when currentNetwork and currentAccount change, in turn the LedgerLiveEthereumProvider will handle those events.
  • finally we write out onDappMessage, which if you recall, will be triggered by LedgerLiveEthereumProvider.request from inside the dapp.
  const onDappMessage = useCallback(
    async (data: JsonRpcRequestMessage) => {
      if (!currentNetwork) {
        console.error("No network selected: ", data);
        return;
      }
      if (!currentAccount) {
        console.error("No account selected: ", data);
        return;
      }

      switch (data.method) {
        case "eth_chainId": {
          postMessage(
            JSON.stringify({
              id: data.id,
              jsonrpc: "2.0",
              result: `0x${currentNetwork.chainID.toString(16)}`,
            }),
          );
          break;
        }
        case "eth_requestAccounts":
        case "enable":
        case "eth_accounts": {
          postMessage(
            JSON.stringify({
              id: data.id,
              jsonrpc: "2.0",
              result: [currentAccount.freshAddress],
            }),
          );
          break;
        }
        case "wallet_switchEthereumChain": {
          const { chainId } = data.params[0];
          const decimalChainId = parseInt(chainId, 16);

          if (isNaN(decimalChainId)) {
            postMessage(
              JSON.stringify({
                id: data.id,
                jsonrpc: "2.0",
                error: rejectedError("Invalid chainId"),
              }),
            );
            break;
          }

          // Check chain ID is known to the wallet
          const requestedCurrency = manifest.dapp?.networks.find(
            network => network.chainID === decimalChainId,
          );

          if (!requestedCurrency) {
            break;
          }

          try {
            await new Promise<void>((resolve, reject) =>
              uiHook["account.request"]({
                currencies: [getCryptoCurrencyById(requestedCurrency.currency)],
                onSuccess: () => {
                  resolve();
                },
                onCancel: () => {
                  reject("User canceled");
                },
              }),
            );
            postMessage(
              JSON.stringify({
                id: data.id,
                jsonrpc: "2.0",
                result: null,
              }),
            );
          }
          break;
        }

        case "eth_sendTransaction": {
          const ethTX = data.params[0];
          const tx = convertEthToLiveTX(ethTX);
          if (
            currentAccount &&
            currentAccount.freshAddress.toLowerCase() === ethTX.from.toLowerCase()
          ) {
            try {
              const options = nanoApp ? { hwAppId: nanoApp } : undefined;

              const signFlowInfos = getWalletAPITransactionSignFlowInfos({
                walletApiTransaction: tx,
                account: currentAccount,
              });

              const signedTransaction = await new Promise<SignedOperation>((resolve, reject) =>
                uiHook["transaction.sign"]({
                  account: currentAccount,
                  parentAccount: undefined,
                  signFlowInfos,
                  options,
                  onSuccess: signedOperation => {
                    resolve(signedOperation);
                  },
                  onError: error => {
                    reject(error);
                  },
                }),
              );

              const bridge = getAccountBridge(currentAccount, undefined);
              const mainAccount = getMainAccount(currentAccount, undefined);

              let optimisticOperation: Operation = signedTransaction.operation;

			   optimisticOperation = await bridge.broadcast({
				  account: mainAccount,
				  signedOperation: signedTransaction,
				});

              uiHook["transaction.broadcast"](
                currentAccount,
                undefined,
                mainAccount,
                optimisticOperation,
              );

              postMessage(
                JSON.stringify({
                  id: data.id,
                  jsonrpc: "2.0",
                  result: optimisticOperation.hash,
                }),
              );
            } 
          }
          break;
        }
        case "personal_sign": {
          try {
            const message = stripHexPrefix(data.params[0]);
            const formattedMessage = prepareMessageToSign(currentAccount, message);
            const signedMessage = await new Promise<string>((resolve, reject) =>
              uiHook["message.sign"]({
                account: currentAccount,
                message: formattedMessage,
                onSuccess: resolve,
                onError: reject,
                onCancel: () => {
                  reject("Canceled by user");
                },
              }),
            );

            postMessage(
              JSON.stringify({
                id: data.id,
                jsonrpc: "2.0",
                result: signedMessage,
              }),
            );
          }
          break;
        }

        case data.method.match(/eth_signTypedData(_v.)?$/)?.input: {
          try {
            const message = data.params[1];
            const formattedMessage = prepareMessageToSign(
              currentAccount,
              Buffer.from(message).toString("hex"),
            );

            const signedMessage = await new Promise<string>((resolve, reject) =>
              uiHook["message.sign"]({
                account: currentAccount,
                message: formattedMessage,
                onSuccess: resolve,
                onError: reject,
                onCancel: () => {
                  reject("Canceled by user");
                },
              }),
            );

            postMessage(
              JSON.stringify({
                id: data.id,
                jsonrpc: "2.0",
                result: signedMessage,
              }),
            );
          } 
          break;
        }

        default: {
          if (currentNetwork.nodeURL?.startsWith("https:")) {
            void network({
              method: "POST",
              url: currentNetwork.nodeURL,
              data,
            }).then(res => {
              postMessage(JSON.stringify(res.data));
            });
          }
          break;
        }
      }
    },
    [currentAccount, currentNetwork, manifest, nanoApp, postMessage,uiHook],
  );

^ I've simplified the code a bit, removing error handling and tracking. But basically it uses ledger live functions to respond to the provider's request made from the dapp. If eth_transactionSign is made, then the uiHook (transaction modal) will be triggered for the user to sign the transaction , etc.

Some more notes on eip 1193 methods:

Some methods introduced by other EIPs are not there yet (do we need them?)

Topbar addition

We can now select an account from inside the live app top bars (only if manifest.dapp exists), the currentAccount variable in useDappLogic (above) will then get changed accordingly. To do so we're using jotai have access to the currentDappAccount that is set

  • when preloading a webview in apps/ledger-live-desktop/src/webviewPreloader/dappPreloader.ts

Final note

For a dapp to use that new logic / baked in ethereumprovider it needs to have an updated manifest with a dapp field in it. [TODO: expand on that, update the documentation]

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment