- good schema difference wallet api / dapp browser
- developer portal doc
- 🔥 https://www.ledger.com/blog/paraswap-defi
A lot of popular wallet implementations use JavaScript injection to dynamically inject their provider. This approach is effective, but could allow the injector to modify the page, presenting a security risk. We avoid this security issue by sandboxing DApps running in Ledger Live. ==The only way to communicate between Ledger Live and the DApp is to use the== ==PostMessage== ==bidirectional API,== allowing secure cross-origin communication.
- https://github.com/ethereum/EIPs/blob/master/EIPS/eip-1193.md
- eip that exposes providers on window.ethereum
- A common convention in the Ethereum web application ("dapp") ecosystem is for key management software ("wallets") to expose their API via a JavaScript object in the web page. This object is called "the Provider".
- https://github.com/ethvault/iframe-provider
- This is an EIP-1193 compliant Ethereum provider that communicates with a parent iframe using the Ethereum JSON RPC.
- https://github.com/LedgerHQ/iframe-provider
- Forked from the repo above
- This was initially built to serve the dApps that integrate with Ethvault. Ledger forked it to update it and use it for integration of DAPPS in Ledger Live
- we make the Web3 provider available through an “iframe Web3 provider”. This “iframe Web3 provider” is an open source project that enables the routing of Web3 calls to the parent iframe container using postMessage. In turn, this postMessage call is routed either to a standard Ethereum node or to Ledger Live when it requires interaction with the Nano S/X or user accounts. blog source
To find live apps using dapp browser, search, yearn and this yearn commit:
Consider it a normal live app, hosted on https://dapp-browser.apps.ledger.com
Here's the entrypoint element:
DappBrowserPage
(pages/index.tsx) renders<DAPPBrowser>
- Processes query params passed to https://dapp-browser.apps.ledger.com
like
accountId
,networks
anddappUrl
- Renders
DAPPBrowser
(src/DAPPBrowser/index.tsx) and passed it some arguments likedappUrl
andinitialAccountId
DappBrowser
sets up send/receive message listenersDappBrowser
fetches all accounts filtered by permissionsDappBrwoser
sets up wallet api like this :ledgerAPI = new LedgerLivePlarformSDK(new WindowMessageTransport());
- logic to set
selectedAccount
, that depends oninitialAccountId
,storedAccount
(in localstorage) and defaults tofilteredAccounts[0]
- has UI logic to select accounts with
<AccountRequest>
(inner top bar) - constructs
dappUrl
, then, ifconnected
&&accounts.length > 0
: displays<DappIframe />
, an iframe that gets passedsrc=dappUrl
to it
- Dapp Browser Refactoring
- Removing a nested layer (Navigation within dapps and simplification of CORS handling).
- Moving the DappBrowserLogic up into the Web3AppWebview that handles all live apps inside LL.
- Remove the need for the Iframe Provider injection by dapp developers
- Deprecation of the DappBrowser repo.
- Support of EIP-6963
- 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.
- check LedgerHQ/iframe-provider#4
- check https://gist.github.com/pedrouid/5d081f8f13d13d12762bc880ce79a000
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
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
frommanifest.dapp?.networks[0]
- set the
currentAccount
usinguseDappAccountLogic
- use postMessage to inform dapp when
currentNetwork
andcurrentAccount
change, in turn theLedgerLiveEthereumProvider
will handle those events. - finally we write out
onDappMessage
, which if you recall, will be triggered byLedgerLiveEthereumProvider.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:
eth_sign
is deprecated and shouldn't be used, https://support.metamask.io/hc/en-us/articles/14764161421467-What-is-eth-sign-and-why-is-it-a-risk, https://docs.metamask.io/wallet/how-to/sign-data/enable
is deprecated an mapped toeth_accounts
, WalletConnect/walletconnect-monorepo#819
Some methods introduced by other EIPs are not there yet (do we need them?)
- https://eips.ethereum.org/EIPS/eip-2255 / https://docs.metamask.io/wallet/reference/wallet_requestpermissions/
- check https://ethereum.org/en/developers/docs/apis/json-rpc/
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
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]