Web3 web applications with clojurescript
Table of Contents
Introduction
This note describes a basic clojurescript web application that interacts with the Ethereum blockchain. It is meant as a demonstration of how to use clojurescript and web3.js. The program is a simple one-page site that lets the connect Metamask and query some smart contracts and even submit a transaction. Functionally it is a very simple front end to Uniswap.
Software
We will need to install the clojure package. Clojure is available in the package repositories of common Linux distributions. Otherwise, follow the instructions at the links above.
Test clojure by running clj
:
$ clj Clojure 1.10.1 user=>
Now that clj
is installed, we can get on to the next steps.
Example code
You can download the code here. Once it's download, extract it into a new directory for the project. The file and folder structure should like like this:
tree . ├── build.edn ├── chains.json ├── deps.edn ├── erc20.json ├── index.html ├── Makefile ├── README ├── src │ └── helloworld │ └── core.cljs ├── uniswapv2router02.json ├── web3-externs.js └── web3.min.js 2 directories, 11 files
Here's how these files fit together.
File | Description |
---|---|
build.edn |
Some settings for the build process including that the file web3.min.js is used to provide the library web3 in our code. |
chains.json |
a list of the differen EVM compatible chains that Metamask can connect with. If for some reason you want to adapt this code to a newer chain that's not listed, you can find the newest file here. |
deps.edn |
the clojurescript dependencies our code uses. |
erc20.json |
the AB I for the ERC20 contract |
index.html |
the entrypoint for our application |
Makefile |
The Makefile for compiling the program |
README |
instructions on how to compile and run the program |
/src/helloworld/core.cljs |
the code for our web application |
uniswapv2router02.json |
the ABI for the Uniswap contract |
web3-externs.js |
This one-line is mysteriously necessary to get the Web3 library to work. |
web3.min.js |
The all important web3.js library that lets our app intereact with the the users Metamask account and the blockchain. More information available here. |
Let's take a look at the code for our web app:
(ns helloworld.core (:require-macros [cljs.core.async.macros :refer [go]]) (:require [reagent.core :as r] [cljs.core.async :refer [<!]] [cljs.core.async.interop :refer-macros [<p!]] [cljs.core.async :refer [go]] [web3] [cljs-http.client :as http] [reagent.dom :as rdom] )) (defn http-provider [uri] (new js/Web3 (new (aget js/Web3 "providers" "HttpProvider") uri))) (defn get-balance [provider address] (js-invoke (aget provider "eth") "getBalance" address)) (defn connection-url [provider] (aget provider "currentProvider" "connection" "_url")) (defn contract-at [provider abi address] (new (aget provider "eth" "Contract") abi address)) (defn get-chain-id [provider & [callback]] (apply js-invoke (aget provider "eth") "getChainId" (remove nil? [callback]))) (defn contract-call [contract-instance method args opts] (js-invoke (apply js-invoke (aget contract-instance "methods") (name method) (clj->js args)) "call" (clj->js opts))) (defn contract-send [contract-instance method args opts] (js-invoke (apply js-invoke (aget contract-instance "methods") (name method) (clj->js args)) "send" (clj->js opts))) (defn supports-ethereum-provider? [] ;;Returns a function or nil. ;; If the function is returned, we call this function to request access. ;; (see below) (some-> js/window (aget "ethereum") (aget "send"))) ; some https://clojuredocs.org/clojure.core/some-%3E (defn full-provider [] ;;"Retrieves the full ethereum provider. ;; should only be called after user clicks connect' (aget js/window "ethereum")) (def walletfound (r/atom nil)) (def walletconnected (r/atom nil)) (def w3-provider nil) (def sendfn (supports-ethereum-provider?)) (if sendfn (reset! walletfound true)) (def bal (r/atom 0)) ;;; uniswap v2 on mainnet & testnets (def uniswapaddr "0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D") (def erc20bal (r/atom 0)) (def erc20decimals (r/atom 0)) (def erc20out (r/atom 0)) (def amtmsg (r/atom nil)) (def chainid (r/atom nil)) (def wpe 1000000000000000000) ; how many wei in 1 ether (defn showchain [info1 info2] (reset! chainid info2)) (defn connect [] (go (let [access (<p! (sendfn "eth_requestAccounts"))] (reset! walletconnected true) (set! w3-provider (new js/Web3 (full-provider))) (get-chain-id w3-provider showchain)))) (defn getbal [addr] (go (let [balance (<p! (get-balance w3-provider addr))] ;; (js/console.log balance) (reset! bal (/ balance wpe))))) (defn getbalerc20 [erc20addr addr] (go (let [erc20abireq (http/get "erc20.json") erc20abi (clj->js (:body (<! erc20abireq))) erc20contract (contract-at w3-provider erc20abi erc20addr) erc20dec (<p! (contract-call erc20contract :decimals [] {} )) erc20balance (<p! (contract-call erc20contract :balanceOf [addr] {}))] ;; (js/console.log addr) ;; (js/console.log erc20addr) ;; (js/console.log erc20decimals) ;; (js/console.log (Math/pow 10 erc20decimals)) ;; (js/console.log (/ erc20balance (Math/pow 10 erc20decimals))) (reset! erc20decimals erc20dec) (reset! erc20bal erc20balance)))) (defn geterc20amt [ethin erc20val weth] (go (let [uniswapabireq (http/get "uniswapv2router02.json") uniswapabi (clj->js (:body (<! uniswapabireq))) uniswapcontract (contract-at w3-provider uniswapabi uniswapaddr) curtime (.now js/Date) unicall (try (reset! amtmsg "") (<p! (contract-call uniswapcontract :getAmountsOut [(js/BigInt (* ethin wpe)), [ weth, ;weth erc20val]] {})) (catch js/Error e (reset! amtmsg "Error! Invalid input amount!") (js/console.log e) nil))] (reset! erc20out (get unicall 1))))) (defn swaperc20 [addr ethin erc20val amountoutmin weth] (go (let [uniswapabireq (http/get "uniswapv2router02.json") uniswapabi (clj->js (:body (<! uniswapabireq))) uniswapcontract (contract-at w3-provider uniswapabi uniswapaddr) curtime (.now js/Date) unicall (<p! (contract-send uniswapcontract :swapExactETHForTokens [ amountoutmin, [ weth, erc20val], addr, (+ curtime 10000) ] { :gas 300000 ;;gas limit (not gas price) :from addr :value (* wpe ethin) }))] (js/console.log unicall)))) (defn atom-input [value] [:input {:type "text" :value @value :on-change (fn [x] (reset! value (-> x .-target .-value)))}]) (defn shared-state [] (let [ ;default value in the address field (VB's address) val (r/atom "0xAb5801a7D398351b8bE11C439e05C5B3259aeC9B") ;default value in the erc20 field. tether on goerli. erc20val (r/atom "0x025332B857aC97ee59B315C364376563D5f1D63D") ; weth on goerli testnet weth (r/atom "0xB4FBF271143F4FBf7B91A5ded31805e42b2208d6") ethin (r/atom 0)] (fn [] [:div [:p "Web3 wallet detected? " [:code (if (nil? @walletfound) "no" "yes")]] [:p "Connected? " [:code (if (nil? @walletconnected) "no" "yes")] " Network id: " @chainid] [:input {:type "button" :value "Connect" :on-click #(connect)}] [:p "Enter eth address: " [atom-input val]] [:input {:type "button" :value "Recalculate" :on-click #(getbal @val)}] [:p "Eth balance is " [:code @bal "." ]] [:p "Enter erc20 address: " [atom-input erc20val]] [:input {:type "button" :value "Recalculate" :on-click #(getbalerc20 @erc20val @val)}] [:p "Erc20 balance is " [:code (/ @erc20bal (Math/pow 10 @erc20decimals)) "." ]] [:p "Enter eth in amount: " [atom-input ethin]] [:p "weth address: " [atom-input weth]] [:input {:type "button" :value "Recalculate" :on-click #(geterc20amt @ethin @erc20val @weth)}] [:p "Amount of erc20 out: " [:code (/ @erc20out (Math/pow 10 @erc20decimals)) "." ] @amtmsg] [:input {:type "button" :value "Swap" :on-click #(swaperc20 @val @ethin @erc20val @erc20out @weth)}] ]))) (defn ^:export run [] (rdom/render [shared-state] (js/document.getElementById "app")))
Compiling the program
We can simply run make
$ make clj -m cljs.main -co build.edn -c helloworld.core
Running this file will make a new folder called out
that includes the js files for our script and it's various dependancies.
Running the code
The entry point for our program is index.html
At this point, one would think that just opening up this file in the browser is enough to launch our app, but this is not the case. Due to security restrictions, Metamask won't interact with local HTML files. However clojure comes to the rescure with a built in server. Here is the command:
$ clj -m cljs.main --serve Serving HTTP on localhost port 9000
You can navigate to localhost:9000 in your browser to see the app. The page should look as follows:
Press connect. Assuming you have Metamask installed, you'll be asked to confirm.
The next thing you can try is to fetch an eth balance. There is an example address listed. Press 'Recalculate' to see the balance.
Afte this, you can enter erc20 contract address, and the 'Recalculate' button will tell you the erc20 balance for the wallet entered at the previous step. Try putting in '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' which corresponds to USDC.
Finally. the remaining fields set up a swap on Uniswap. Note that for this to work, you need to make sure the first eth address at the top of the page is the users wallet address. Warning: Actually doing the swap will send a transaction to mainnet, which is of course irreversible. Proceed with caution! For the weth address, enter '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2' which is the address of weth on mainnet. For the given 'eth' amount, pressing recalculate will tell you how much of the erc20 you can expect to get for the given amount of eth.
Pressing swap the user will be prompted for a transaction.
References
- https://clojure.org/guides/getting_started
- https://github.com/ethereum/web3.js
- https://github.com/district0x/cljs-web3-next
- https://chainid.network/
- https://medium.com/@awantoch/how-to-connect-web3-js-to-metamask-in-2020-fee2b2edf58a
- https://www.learn-clojurescript.com/section-4/lesson-24-handling-exceptions-and-errors/
- https://docs.metamask.io/guide/ethereum-provider.html
- https://github.com/cljsjs
- https://clojure.github.io/core.async/#clojure.core.async/go
- https://reagent-project.github.io/