Recipe: wallets and web3
PyWeb has nothing web3-specific built in, and doesn't need it: wallet libraries are npm packages, and verifying a signature is a few lines of Python. This recipe signs users in with their Ethereum wallet ("Sign-In with Ethereum" style) using ethers in the browser and eth-account on the server.
#Install
pip install eth-account
pyweb add etherspyweb add vendors ethers and its dependencies (about 150 files) into static/vendor/; pages that don't use it don't load it.
#The app
import secrets
import time
from eth_account import Account
from eth_account.messages import encode_defunct
from pyweb import App, RPCError, npm, server, session
ethers = npm("ethers", "*")
app = App(title="Sign in with a wallet")
NONCES = {} # nonce -> expiry; use your database or cache with several processes
@server
def challenge() -> str:
"""A one-time message for the wallet to sign."""
nonce = secrets.token_hex(16)
NONCES[nonce] = time.time() + 300
return f"Sign in to {app.title}\nNonce: {nonce}"
@server
def sign_in(message: str, signature: str) -> str:
nonce = message.rsplit("Nonce: ", 1)[-1]
if NONCES.pop(nonce, 0) < time.time():
raise RPCError("unauthenticated", "that sign-in request expired; try again")
address = Account.recover_message(encode_defunct(text=message), signature=signature)
session.login(address)
return address
@server
def sign_out() -> None:
session.logout()
@app.page("/")
def Home():
user = session.user()
address = user["sub"] if user else ""
error = ""
async def connect():
error = ""
if not window.ethereum:
error = "No wallet found: install one such as MetaMask or Rabby."
return
try:
provider = ethers.BrowserProvider(window.ethereum)
signer = await provider.getSigner()
message = challenge()
signature = await signer.signMessage(message)
address = sign_in(message, signature)
except RPCError as e:
error = str(e)
def leave():
sign_out()
address = ""
<main>
if address:
<p id="who">Signed in as <code>{address}</code></p>
<button onclick={leave}>Sign out</button>
else:
<button id="connect" onclick={connect}>Connect wallet</button>
<p class="error">{error}</p>
</main>What this compiles to
// Home.js
import * as $npm_ethers___394ab8 from "ethers";
function Home($s) {
const address = $signal("address" in $s ? $s["address"] : null);
const error = $signal("error" in $s ? $s["error"] : "");
async function connect() {
let e, message, provider, signature, signer;
error("");
if (!$py.truth(window.ethereum)) {
error("No wallet found: install one such as MetaMask or Rabby.");
return;
}
try {
provider = $py.callm($npm_ethers___394ab8, "BrowserProvider", [window.ethereum]);
signer = (await provider.getSigner());
message = (await $rpc("challenge", {}));
signature = (await signer.signMessage(message));
address((await $rpc("sign_in", {"message": message, "signature": signature})));
} catch ($err) {
if ($py.exc($err, ["RPCError"])) {
e = $err;
error($py.str(e));
} else { throw $err; }
}
}
async function leave() {
(await $rpc("sign_out", {}));
address("");
}
return [$h("main", null, () => [
$when(() => $py.truth(address()), () => [
$h("p", {"id": "who"}, () => [
$t("Signed in as "),
$h("code", null, () => [$dyn(() => address())])
]),
$h("button", {"onclick": leave}, () => [$t("Sign out")])
], () => [$h("button", {"id": "connect", "onclick": connect}, () => [$t("Connect wallet")])]),
$h("p", {"class": "error"}, () => [$dyn(() => error())])
])];
}
$mount("Home", Home);page Home route=/
server user computed per request; never read by browser code, never sent
browser address reactive state: assigned in connect(); initial value computed on the server and sent
browser error reactive state: assigned in connect(); literal initial value
browser connect event handler (compiled to JavaScript)
browser leave event handler (compiled to JavaScript)
rpc POST /__pyweb/rpc/challenge () -> str
rpc POST /__pyweb/rpc/sign_in (message: str, signature: str) -> str
rpc POST /__pyweb/rpc/sign_out () -> None<main>
<button id="connect">Connect wallet</button>
<p class="error"></p>
</main>How it works:
- The server makes a one-time message (
challenge). The nonce stops a signature from being reused, and it expires after five minutes. - The wallet signs it in the browser.
ethers.BrowserProvidertalks to whatever wallet the visitor has installed (it injectswindow.ethereum);signMessageasks them to approve. - The server recovers the address from the signature (
Account.recover_message) and signs the visitor in with it as their user id. The private key never leaves the wallet, and the browser can't claim an address it can't sign for.
From there, session.user()["sub"] is the wallet address in any page or server function.
#Reading the chain
For balances, contract calls and transactions, use ethers in browser code, through the visitor's wallet:
from pyweb import App, npm
ethers = npm("ethers", "*")
app = App()
@app.page("/balance")
def Balance():
balance = ""
async def check():
provider = ethers.BrowserProvider(window.ethereum)
signer = await provider.getSigner()
wei = await provider.getBalance(await signer.getAddress())
balance = ethers.formatEther(wei) + " ETH"
<button onclick={check}>Show my balance</button>
<p>{balance}</p>What this compiles to
// Balance.js
import * as $npm_ethers___394ab8 from "ethers";
function Balance($s) {
const balance = $signal("balance" in $s ? $s["balance"] : "");
async function check() {
let provider, signer, wei;
provider = $py.callm($npm_ethers___394ab8, "BrowserProvider", [window.ethereum]);
signer = (await provider.getSigner());
wei = (await provider.getBalance((await signer.getAddress())));
balance($py.add($py.callm($npm_ethers___394ab8, "formatEther", [wei]), " ETH"));
}
return [
$h("button", {"onclick": check}, () => [$t("Show my balance")]),
$h("p", null, () => [$dyn(() => balance())])
];
}
$mount("Balance", Balance);page Balance route=/balance
browser balance reactive state: assigned in check(); literal initial value
browser check event handler (compiled to JavaScript)<button>Show my balance</button>
<p></p>Or read the chain from the server with any Python library (for example web3.py with an RPC provider URL in an environment variable) inside @server functions, which keeps provider keys off the page.
#Notes
- Keep
NONCESsomewhere shared (your database, orpyweb.cachewith Redis) when you run several processes. - Use the address as the user id, and store profile data in your own tables keyed by it.
- The same pattern works for other chains with their own npm wallet library and Python verification package.