From 215fec9bca96113fad80654a42caf9f40a76d782 Mon Sep 17 00:00:00 2001 From: Daniel Schiavini Date: Fri, 16 Feb 2024 11:13:45 +0100 Subject: [PATCH 1/4] Improve Jupyter performance - Minified JS with command `npx uglify-js --mangle -o jupyter.min.js -- jupyter.js` - Instead of injecting the whole ethersJS library, just inline the wallet calls --- boa/integrations/jupyter/browser.py | 36 +++++++++++++++---------- boa/integrations/jupyter/constants.py | 1 - boa/integrations/jupyter/jupyter.js | 31 ++++++++++++--------- boa/integrations/jupyter/jupyter.min.js | 1 + boa/integrations/jupyter/utils.py | 10 ++----- 5 files changed, 44 insertions(+), 35 deletions(-) create mode 100644 boa/integrations/jupyter/jupyter.min.js diff --git a/boa/integrations/jupyter/browser.py b/boa/integrations/jupyter/browser.py index 65a47615..f885d508 100644 --- a/boa/integrations/jupyter/browser.py +++ b/boa/integrations/jupyter/browser.py @@ -29,6 +29,7 @@ ) from boa.network import NetworkEnv from boa.rpc import RPC, RPCError +from boa.util.abi import Address try: from google.colab.output import eval_js as colab_eval_js @@ -39,6 +40,14 @@ nest_asyncio.apply() +logging.warning(f"Colab {bool(colab_eval_js)}") +if not colab_eval_js: + # colab creates a new iframe for every call, we need to re-inject it every time + # for jupyterlab we only need to do it once + logging.warning("Installing Jupyter triggers") + install_jupyter_javascript_triggers() + + class BrowserSigner: """ A BrowserSigner is a class that can be used to sign transactions in IPython/JupyterLab. @@ -49,12 +58,10 @@ def __init__(self, address=None): Create a BrowserSigner instance. :param address: The account address. If not provided, it will be requested from the browser. """ - if address is not None: - self.address = address - else: - self.address = _javascript_call( - "loadSigner", timeout_message=ADDRESS_TIMEOUT_MESSAGE - ) + address = _javascript_call( + "loadSigner", address, timeout_message=ADDRESS_TIMEOUT_MESSAGE + ) + self.address = Address(address) def send_transaction(self, tx_data: dict) -> dict: """ @@ -134,16 +141,17 @@ def __init__(self, address=None): self.signer = BrowserSigner(address) self.set_eoa(self.signer) - def get_chain_id(self): - return _javascript_call( + def get_chain_id(self) -> int: + chain_id = _javascript_call( "rpc", "eth_chainId", timeout_message=RPC_TIMEOUT_MESSAGE ) + return int.from_bytes(bytes.fromhex(chain_id[2:]), "big") - def set_chain_id(self, chain_id): + def set_chain_id(self, chain_id: int | str): _javascript_call( "rpc", "wallet_switchEthereumChain", - [{"chainId": chain_id}], + [{"chainId": chain_id if isinstance(chain_id, str) else hex(chain_id)}], timeout_message=RPC_TIMEOUT_MESSAGE, ) self._reset_fork() @@ -160,14 +168,13 @@ def _javascript_call(js_func: str, *args, timeout_message: str) -> Any: :param kwargs: The arguments to pass to the Javascript snippet. :return: The result of the Javascript snippet sent to the API. """ - install_jupyter_javascript_triggers() - token = _generate_token() args_str = ", ".join(json.dumps(p) for p in chain([token], args)) - js_code = f"window._titanoboa.{js_func}({args_str})" + js_code = f"window._titanoboa.{js_func}({args_str});" # logging.warning(f"Calling {js_func} with {args_str}") if colab_eval_js: + install_jupyter_javascript_triggers() result = colab_eval_js(js_code) return _parse_js_result(json.loads(result)) @@ -175,7 +182,8 @@ def _javascript_call(js_func: str, *args, timeout_message: str) -> Any: logging.info(f"Waiting for {token}") try: memory.buf[:1] = NUL - display(Javascript(js_code)) + hide_output_element = "element.style.display = 'none';" + display(Javascript(js_code + hide_output_element)) message_bytes = _wait_buffer_set(memory.buf, timeout_message) return _parse_js_result(json.loads(message_bytes.decode())) finally: diff --git a/boa/integrations/jupyter/constants.py b/boa/integrations/jupyter/constants.py index 6f794a06..db3ac8e3 100644 --- a/boa/integrations/jupyter/constants.py +++ b/boa/integrations/jupyter/constants.py @@ -4,7 +4,6 @@ CALLBACK_TOKEN_TIMEOUT = timedelta(minutes=3) SHARED_MEMORY_LENGTH = 50 * 1024 + len(NUL) # Size of the shared memory object CALLBACK_TOKEN_BYTES = 32 -ETHERS_JS_URL = "https://cdnjs.cloudflare.com/ajax/libs/ethers/6.9.0/ethers.umd.min.js" PLUGIN_NAME = "titanoboa_jupyterlab" TOKEN_REGEX = rf"{PLUGIN_NAME}_[0-9a-fA-F]{{{CALLBACK_TOKEN_BYTES * 2}}}" TRANSACTION_TIMEOUT_MESSAGE = ( diff --git a/boa/integrations/jupyter/jupyter.js b/boa/integrations/jupyter/jupyter.js index f6528744..c64b548e 100644 --- a/boa/integrations/jupyter/jupyter.js +++ b/boa/integrations/jupyter/jupyter.js @@ -3,14 +3,12 @@ * BrowserSigner to the frontend. */ (() => { - let provider; // cache the provider to avoid re-creating it every time - const getEthersProvider = () => { - if (provider) return provider; + const eth = (method, params) => { const {ethereum} = window; if (!ethereum) { throw new Error('No Ethereum plugin found. Please authorize the site on your browser wallet.'); } - return provider = new ethers.BrowserProvider(ethereum); + return ethereum.request({method, params}); }; /** Stringify data, converting big ints to strings */ @@ -40,19 +38,24 @@ return response.text(); } - const getSigner = () => getEthersProvider().getSigner(); - - /** Load the signer via ethers user */ - const loadSigner = () => getSigner().then(s => s.getAddress()); + let from; + const loadSigner = async (address) => { + const accounts = await eth('eth_requestAccounts'); + from = accounts.includes(address) ? address : accounts[0]; + return from; + }; /** Sign a transaction via ethers */ - const sendTransaction = transaction => getSigner().then(s => s.sendTransaction(transaction)); + const sendTransaction = async transaction => ({"hash": await eth('eth_sendTransaction', [transaction])}); /** Sign a typed data via ethers */ - const signTypedData = (domain, types, value) => getSigner().then(s => s.signTypedData(domain, types, value)); + const signTypedData = (domain, types, value) => eth( + 'eth_signTypedData_v4', + [from, JSON.stringify({domain, types, value})] + ); /** Call an RPC method via ethers */ - const rpc = (method, params) => getEthersProvider().send(method, params); + const rpc = (method, params) => eth(method, params); /** Wait until the transaction is mined */ const waitForTransactionReceipt = async (tx_hash, timeout, poll_latency) => { @@ -83,6 +86,7 @@ /** Call the backend when the given function is called, handling errors */ const handleCallback = func => async (token, ...args) => { + const start = Date.now(); if (!colab) { // Check if the cell was already executed. In Colab, eval_js() doesn't replay. const response = await fetch(`../titanoboa_jupyterlab/callback/${token}`); @@ -91,7 +95,8 @@ } const body = stringify(await parsePromise(func(...args))); - // console.log(`Boa: ${func.name}(${args.map(a => JSON.stringify(a)).join(',')}) = ${body};`); + const duration = Date.now() - start; + console.log(`Boa (${duration}ms): ${func.name}(${args.map(a => JSON.stringify(a)).join(',')}) = ${body};`); if (colab) { return body; } @@ -107,4 +112,6 @@ rpc: handleCallback(rpc), multiRpc: handleCallback(multiRpc), }; + + if (element) element.style.display = "none"; // hide the output element in JupyterLab })(); diff --git a/boa/integrations/jupyter/jupyter.min.js b/boa/integrations/jupyter/jupyter.min.js new file mode 100644 index 00000000..86208e2c --- /dev/null +++ b/boa/integrations/jupyter/jupyter.min.js @@ -0,0 +1 @@ +(()=>{const o=(t,e)=>{const{ethereum:n}=window;if(!n){throw new Error("No Ethereum plugin found. Please authorize the site on your browser wallet.")}return n.request({method:t,params:e})};const i=t=>JSON.stringify(t,(t,e)=>typeof e==="bigint"?e.toString():e);const c=t=>document.cookie.match(`\\b${t}=([^;]*)\\b`)?.[1];const r=t=>t.then(t=>({data:t})).catch(t=>({error:Object.keys(t).length?t:{message:t.message,stack:t.stack}}));const a=e=>new Promise(t=>setTimeout(t,e));const w=window.colab??window.google?.colab;async function l(t,e){const n={["X-XSRFToken"]:c("_xsrf")};const o={method:"POST",body:e,headers:n};const a=`../titanoboa_jupyterlab/callback/${t}`;const s=await fetch(a,o);return s.text()}let s;const t=async t=>{const e=await o("eth_requestAccounts");s=e.includes(t)?t:e[0];return s};const e=async t=>({hash:await o("eth_sendTransaction",[t])});const n=(t,e,n)=>o("eth_signTypedData_v4",[s,JSON.stringify({domain:t,types:e,value:n})]);const u=(t,e)=>o(t,e);const h=async(t,e,n)=>{while(true){try{const o=await u("eth_getTransactionReceipt",[t]);if(o){return o}}catch(t){if((t?.info||t)?.error?.code!==-32603){throw t}}if(et.reduce(async(t,[e,n])=>[...await t,await u(e,n)],[]);const y=c=>async(t,...e)=>{const n=Date.now();if(!w){const s=await fetch(`../titanoboa_jupyterlab/callback/${t}`);if(!s.ok)return}const o=i(await r(c(...e)));const a=Date.now()-n;console.log(`Boa (${a}ms): ${c.name}(${e.map(t=>JSON.stringify(t)).join(",")}) = ${o};`);if(w){return o}await l(t,o)};window._titanoboa={loadSigner:y(t),sendTransaction:y(e),signTypedData:y(n),waitForTransactionReceipt:y(h),rpc:y(u),multiRpc:y(d)};if(element)element.style.display="none"})(); diff --git a/boa/integrations/jupyter/utils.py b/boa/integrations/jupyter/utils.py index c14622a3..5359c751 100644 --- a/boa/integrations/jupyter/utils.py +++ b/boa/integrations/jupyter/utils.py @@ -1,20 +1,14 @@ from os.path import dirname, join, realpath -import requests from IPython.display import Javascript, display -from boa.integrations.jupyter.constants import ETHERS_JS_URL - def install_jupyter_javascript_triggers(): """Run the ethers and titanoboa_jupyterlab Javascript snippets in the browser.""" - ethers_js = requests.get(ETHERS_JS_URL) - cur_dir = dirname(realpath(__file__)) - with open(join(cur_dir, "jupyter.js")) as f: + with open(join(cur_dir, "jupyter.min.js")) as f: jupyter_js = f.read() - - display(Javascript(ethers_js.text + jupyter_js)) + display(Javascript(jupyter_js)) def convert_frontend_dict(data): From 728c6986ad2c8e3b090948e54246ae09edc35af9 Mon Sep 17 00:00:00 2001 From: Daniel Schiavini Date: Fri, 16 Feb 2024 14:59:56 +0100 Subject: [PATCH 2/4] Do not minify --- boa/integrations/jupyter/jupyter.min.js | 1 - boa/integrations/jupyter/utils.py | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) delete mode 100644 boa/integrations/jupyter/jupyter.min.js diff --git a/boa/integrations/jupyter/jupyter.min.js b/boa/integrations/jupyter/jupyter.min.js deleted file mode 100644 index 86208e2c..00000000 --- a/boa/integrations/jupyter/jupyter.min.js +++ /dev/null @@ -1 +0,0 @@ -(()=>{const o=(t,e)=>{const{ethereum:n}=window;if(!n){throw new Error("No Ethereum plugin found. Please authorize the site on your browser wallet.")}return n.request({method:t,params:e})};const i=t=>JSON.stringify(t,(t,e)=>typeof e==="bigint"?e.toString():e);const c=t=>document.cookie.match(`\\b${t}=([^;]*)\\b`)?.[1];const r=t=>t.then(t=>({data:t})).catch(t=>({error:Object.keys(t).length?t:{message:t.message,stack:t.stack}}));const a=e=>new Promise(t=>setTimeout(t,e));const w=window.colab??window.google?.colab;async function l(t,e){const n={["X-XSRFToken"]:c("_xsrf")};const o={method:"POST",body:e,headers:n};const a=`../titanoboa_jupyterlab/callback/${t}`;const s=await fetch(a,o);return s.text()}let s;const t=async t=>{const e=await o("eth_requestAccounts");s=e.includes(t)?t:e[0];return s};const e=async t=>({hash:await o("eth_sendTransaction",[t])});const n=(t,e,n)=>o("eth_signTypedData_v4",[s,JSON.stringify({domain:t,types:e,value:n})]);const u=(t,e)=>o(t,e);const h=async(t,e,n)=>{while(true){try{const o=await u("eth_getTransactionReceipt",[t]);if(o){return o}}catch(t){if((t?.info||t)?.error?.code!==-32603){throw t}}if(et.reduce(async(t,[e,n])=>[...await t,await u(e,n)],[]);const y=c=>async(t,...e)=>{const n=Date.now();if(!w){const s=await fetch(`../titanoboa_jupyterlab/callback/${t}`);if(!s.ok)return}const o=i(await r(c(...e)));const a=Date.now()-n;console.log(`Boa (${a}ms): ${c.name}(${e.map(t=>JSON.stringify(t)).join(",")}) = ${o};`);if(w){return o}await l(t,o)};window._titanoboa={loadSigner:y(t),sendTransaction:y(e),signTypedData:y(n),waitForTransactionReceipt:y(h),rpc:y(u),multiRpc:y(d)};if(element)element.style.display="none"})(); diff --git a/boa/integrations/jupyter/utils.py b/boa/integrations/jupyter/utils.py index 5359c751..f1d3d642 100644 --- a/boa/integrations/jupyter/utils.py +++ b/boa/integrations/jupyter/utils.py @@ -6,7 +6,7 @@ def install_jupyter_javascript_triggers(): """Run the ethers and titanoboa_jupyterlab Javascript snippets in the browser.""" cur_dir = dirname(realpath(__file__)) - with open(join(cur_dir, "jupyter.min.js")) as f: + with open(join(cur_dir, "jupyter.js")) as f: jupyter_js = f.read() display(Javascript(jupyter_js)) From f9e494643f8af21a6e07524ac541fcf32f2e7efd Mon Sep 17 00:00:00 2001 From: Daniel Schiavini Date: Fri, 16 Feb 2024 15:02:35 +0100 Subject: [PATCH 3/4] Remove logging --- boa/integrations/jupyter/browser.py | 2 -- boa/integrations/jupyter/jupyter.js | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/boa/integrations/jupyter/browser.py b/boa/integrations/jupyter/browser.py index f885d508..37a2bf00 100644 --- a/boa/integrations/jupyter/browser.py +++ b/boa/integrations/jupyter/browser.py @@ -40,11 +40,9 @@ nest_asyncio.apply() -logging.warning(f"Colab {bool(colab_eval_js)}") if not colab_eval_js: # colab creates a new iframe for every call, we need to re-inject it every time # for jupyterlab we only need to do it once - logging.warning("Installing Jupyter triggers") install_jupyter_javascript_triggers() diff --git a/boa/integrations/jupyter/jupyter.js b/boa/integrations/jupyter/jupyter.js index c64b548e..65bb524e 100644 --- a/boa/integrations/jupyter/jupyter.js +++ b/boa/integrations/jupyter/jupyter.js @@ -96,7 +96,7 @@ const body = stringify(await parsePromise(func(...args))); const duration = Date.now() - start; - console.log(`Boa (${duration}ms): ${func.name}(${args.map(a => JSON.stringify(a)).join(',')}) = ${body};`); + // console.log(`Boa (${duration}ms): ${func.name}(${args.map(a => JSON.stringify(a)).join(',')}) = ${body};`); if (colab) { return body; } From 3f6459a9924b68117cc3709305d6acfcf81cdbdc Mon Sep 17 00:00:00 2001 From: Daniel Schiavini Date: Fri, 16 Feb 2024 15:49:43 +0100 Subject: [PATCH 4/4] Remove unused vars --- boa/integrations/jupyter/jupyter.js | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/boa/integrations/jupyter/jupyter.js b/boa/integrations/jupyter/jupyter.js index 65bb524e..ab09833c 100644 --- a/boa/integrations/jupyter/jupyter.js +++ b/boa/integrations/jupyter/jupyter.js @@ -3,7 +3,7 @@ * BrowserSigner to the frontend. */ (() => { - const eth = (method, params) => { + const rpc = (method, params) => { const {ethereum} = window; if (!ethereum) { throw new Error('No Ethereum plugin found. Please authorize the site on your browser wallet.'); @@ -40,23 +40,20 @@ let from; const loadSigner = async (address) => { - const accounts = await eth('eth_requestAccounts'); + const accounts = await rpc('eth_requestAccounts'); from = accounts.includes(address) ? address : accounts[0]; return from; }; /** Sign a transaction via ethers */ - const sendTransaction = async transaction => ({"hash": await eth('eth_sendTransaction', [transaction])}); + const sendTransaction = async transaction => ({"hash": await rpc('eth_sendTransaction', [transaction])}); /** Sign a typed data via ethers */ - const signTypedData = (domain, types, value) => eth( + const signTypedData = (domain, types, value) => rpc( 'eth_signTypedData_v4', [from, JSON.stringify({domain, types, value})] ); - /** Call an RPC method via ethers */ - const rpc = (method, params) => eth(method, params); - /** Wait until the transaction is mined */ const waitForTransactionReceipt = async (tx_hash, timeout, poll_latency) => { while (true) { @@ -86,7 +83,6 @@ /** Call the backend when the given function is called, handling errors */ const handleCallback = func => async (token, ...args) => { - const start = Date.now(); if (!colab) { // Check if the cell was already executed. In Colab, eval_js() doesn't replay. const response = await fetch(`../titanoboa_jupyterlab/callback/${token}`); @@ -95,8 +91,7 @@ } const body = stringify(await parsePromise(func(...args))); - const duration = Date.now() - start; - // console.log(`Boa (${duration}ms): ${func.name}(${args.map(a => JSON.stringify(a)).join(',')}) = ${body};`); + // console.log(`Boa: ${func.name}(${args.map(a => JSON.stringify(a)).join(',')}) = ${body};`); if (colab) { return body; }