Validation

Ethereum addresses

Ethereum without a node: EIP-55 checksums checked and written, addresses from public keys in any spelling, Keccak-256, personal_sign messages signed, recovered and verified for sign-in with a wallet, and EIP-681 payment URIs read and built, `2.014e18` amounts kept exact. secp256k1 and Keccak in pure Rust.

Version 1.0.0 · 11 functions · 4 crates

#ethereum#eip-55#secp256k1#keccak#wallet

Functions

What use eth_address.qwer gives a program, with the types Quillo sees. To take only some, or under other names: use eth_checksum, eth_check from eth_address (Quillo 0.6).

eth_checksum(address: str) → stror an error

An address with its EIP-55 capitals: "0xfb6916095ca1df60bb79ce92ce3ea74c37c5d359" is "0xfB6916095ca1df60bB79Ce92cE3Ea74c37c5d359".

eth_check(address: str) → any

An address checked: { valid, address, checksummed, reason }. All small or all capital letters are valid but carry no checksum, so checksummed is false; mixed case must match EIP-55, and reason shows the right spelling if not.

eth_keccak(data: str) → stror an error

Keccak-256 as Ethereum uses it, not SHA3-256: of text, or of bytes given as 0x... hex. Gives 0x and 64 digits.

eth_from_public_key(public_key: str) → stror an error

The address of a public key in hex: 65 bytes starting 04, the 64 without it, or 33 compressed. A point not on the curve is an error value.

eth_personal_hash(message: str) → stror an error

The hash personal_sign signs: keccak of "\x19Ethereum Signed Message:\n", the length in bytes, and the message. A message of 0x hex is taken as those bytes, as wallets do.

eth_recover(message: str, signature: str) → anyor an error

Who signed a message with personal_sign: { address, public_key, hash, high_s }. high_s true means a signature Ethereum transactions refuse under EIP-2, though recovery still gives an address.

eth_recover_hash(hash: str, signature: str) → stror an error

Who signed a 32-byte hash, for signatures not made by personal_sign (EIP-712 digests, for instance): gives the address.

eth_verify(message: str, signature: str, address: str) → boolor an error

Whether a message was signed by an address, for "sign in with a wallet". false for someone else's signature; an error value only when the address or signature is malformed.

eth_sign(message: str, private_key: str) → anyor an error

A message signed the personal_sign way, with deterministic RFC 6979 nonces: { signature, address, r, s, v }. Read the key from env, never from a request.

eth_uri_parse(uri: str) → anyor an error

An EIP-681 ethereum: URI read: { prefix, target, chain_id, function, value, gas, gas_limit, gas_price, params, token_transfer }. 2.014e18 amounts become exact decimal text. For the ERC-20 transfer function, token_transfer has { token, to, amount }.

eth_uri_build(request: any) → stror an error

An EIP-681 URI from { to, chain_id, value (wei as text) } for a plain payment, or { token, to, amount, chain_id } for an ERC-20 transfer. Addresses are written with their checksum.

Get it

$ quillo install eth_address               # eth_address.qwer into this folder$ quillo install eth_address --example     # and its example, eth_address.qwe

Or with curl:

$ curl -O https://quillolang.org/plugins/files/eth_address.qwer$ curl -O https://quillolang.org/plugins/files/eth_address.qwe

Then, in your program, all of it or only what you call:

main.qwe
use eth_address.qweruse eth_checksum, eth_check from eth_address      // Quillo 0.6: only these

It uses these crates, which cargo fetches the first time the plugin is built:

[dependencies]
k256 = { version = "0.13", default-features = false, features = ["ecdsa", "arithmetic", "std"] }sha3 = "0.10"hex = "0.4"serde_json = { version = "1", features = ["preserve_order"] }

Example

eth_address.qwe
// Ethereum addresses and signatures without a node: EIP-55 checksums, an address from a public// key, a wallet's personal_sign checked (the sign-in step), and EIP-681 payment links.////   quillo run eth_address.qwe//   then open http://localhost:8080/check?address=0xfb6916095ca1df60bb79ce92ce3ea74c37c5d359,//   /keys, /recover, /sign and /uri//// /sign needs a key of your own, a throwaway one and never one holding money://   ETH_DEMO_KEY=0x... quillo run eth_address.qweuse "eth_address.qwer"route GET /check ? { address: str } = eth_check(address)route GET /checks = {    right: eth_checksum("0xde709f2102306220921060314715629080e2fb77")?,    upper: eth_check("0xFB6916095CA1DF60BB79CE92CE3EA74C37C5D359"),    wrong: eth_check("0xfB6916095ca1df60bB79Ce92cE3Ea74c37c5d358".replace("c5d358", "c5D358")),    short: eth_check("0x1234"),    keccak_empty: eth_keccak("")?,    keccak_hex: eth_keccak("0x616263")?,}// secp256k1's generator point: the public key of private key 1, in all three spellings.let g = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8"route GET /keys = {    plain: eth_from_public_key("0x" + g)?,    uncompressed: eth_from_public_key("04" + g)?,    compressed: eth_from_public_key("0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798")?,    not_on_curve: eth_from_public_key("04" + replace(g, "79be", "79bf")).error,}// web3.js's published example: the message "Some data" signed by this account.let account = "0x2c7536E3605D9C16a7a3D7b1898e529396a65c23"let signed = "0xb91467e570a6466aa9e9876cbcd013baba02900b8979d43fe208a4a4f339f5fd6007e74cd82e037b800186422fc2da167c747ef045e5d18a5f5d4300f8e1a0291c"route GET /recover = {    who: eth_recover("Some data", signed)?,    hash: eth_personal_hash("Some data")?,    genuine: eth_verify("Some data", signed, account)?,    other_text: eth_verify("Other data", signed, account)?,    bad_v: eth_recover("Some data", slice(signed, 0, 130) + "05").error,    short: eth_recover("Some data", "0x1234").error,}let key = env("ETH_DEMO_KEY", "")route GET /sign {    if key == "" {        return respond(400, "Set ETH_DEMO_KEY to a throwaway key to try this.")    }    let s = eth_sign("Some data", key)?    { signature: s.signature, address: s.address, checks: eth_verify("Some data", s.signature, s.address)? }}route GET /uri = {    pay: eth_uri_parse("ethereum:0xfb6916095ca1df60bb79ce92ce3ea74c37c5d359?value=2.014e18")?,    usdc: eth_uri_parse("ethereum:0x89205a3a3b2a69de6dbf7f01ed13b2108b2c43e7/transfer?address=0x8e23ee67d1332ad560396262c48ffbb01f93d052&uint256=1")?.token_transfer,    chain: eth_uri_parse("ethereum:pay-0xfb6916095ca1df60bb79ce92ce3ea74c37c5d359@137?value=1e18&gasPrice=2.5e9")?,    built: eth_uri_build({ to: "0xfb6916095ca1df60bb79ce92ce3ea74c37c5d359", value: "1e15", chain_id: 10 })?,    token: eth_uri_build({ token: "0x89205a3a3b2a69de6dbf7f01ed13b2108b2c43e7", to: "vitalik.eth", amount: "2.5e6" })?,    bad: eth_uri_parse("ethereum:0x1234").error,    fraction: eth_uri_parse("ethereum:0xfb6916095ca1df60bb79ce92ce3ea74c37c5d359?value=1.5").error,}
$ quillo run eth_address.qwe$ curl 'localhost:8080/check?address=0xfb6916095ca1df60bb79ce92ce3ea74c37c5d359'$ curl 'localhost:8080/checks'$ curl 'localhost:8080/keys'$ curl 'localhost:8080/recover'$ curl 'localhost:8080/sign'$ curl 'localhost:8080/uri'

Source

eth_address.qwer
//! Ethereum addresses and signatures in pure Rust: EIP-55 checksums, addresses from public keys, EIP-681 payment URIs, Keccak-256, and personal_sign messages signed, recovered and verified on secp256k1.//! version: 1.0.0//!//! [dependencies]//! k256 = { version = "0.13", default-features = false, features = ["ecdsa", "arithmetic", "std"] }//! sha3 = "0.10"//! hex = "0.4"//! serde_json = { version = "1", features = ["preserve_order"] }use k256::ecdsa::{RecoveryId, Signature, SigningKey, VerifyingKey};use k256::elliptic_curve::sec1::ToEncodedPoint;use serde_json::{json, Map, Value};use sha3::{Digest, Keccak256};fn keccak(b: &[u8]) -> [u8; 32] {    Keccak256::digest(b).into()}fn unhex(s: &str, what: &str) -> Result<Vec<u8>, String> {    let t = s.trim();    let t = t.strip_prefix("0x").or(t.strip_prefix("0X")).unwrap_or(t);    hex::decode(t).map_err(|_| format!("the {} is not hex: `{}`", what, s))}fn checksum(lower: &str) -> String {    let h = hex::encode(keccak(lower.as_bytes()));    let mut out = String::from("0x");    for (c, n) in lower.chars().zip(h.chars()) {        out.push(if n.to_digit(16).unwrap_or(0) >= 8 { c.to_ascii_uppercase() } else { c });    }    out}fn body(address: &str) -> Result<&str, String> {    let a = address.trim();    let b = a.strip_prefix("0x").or(a.strip_prefix("0X")).ok_or(format!("`{}` does not start with 0x", address))?;    if b.len() != 40 {        return Err(format!("`{}` has {} hex digits; an address has 40", address, b.len()));    }    if let Some(c) = b.chars().find(|c| !c.is_ascii_hexdigit()) {        return Err(format!("`{}` has `{}`, which is not a hex digit", address, c));    }    Ok(b)}/// An address with its EIP-55 capitals: "0xfb6916095ca1df60bb79ce92ce3ea74c37c5d359" is "0xfB6916095ca1df60bB79Ce92cE3Ea74c37c5d359".pub fn eth_checksum(address: &str) -> Result<String, String> {    Ok(checksum(&body(address)?.to_lowercase()))}/// An address checked: { valid, address, checksummed, reason }. All small or all capital letters are valid but carry no checksum, so `checksummed` is false; mixed case must match EIP-55, and `reason` shows the right spelling if not.pub fn eth_check(address: &str) -> Value {    let b = match body(address) {        Ok(b) => b,        Err(e) => return json!({ "valid": false, "address": null, "checksummed": false, "reason": e }),    };    let right = checksum(&b.to_lowercase());    let mixed = b.bytes().any(|c| c.is_ascii_lowercase()) && b.bytes().any(|c| c.is_ascii_uppercase());    if mixed && right[2..] != *b {        return json!({ "valid": false, "address": null, "checksummed": false, "reason": format!("the EIP-55 checksum fails: it should be {}", right) });    }    json!({ "valid": true, "address": right, "checksummed": mixed, "reason": null })}/// Keccak-256 as Ethereum uses it, not SHA3-256: of text, or of bytes given as `0x...` hex. Gives `0x` and 64 digits.pub fn eth_keccak(data: &str) -> Result<String, String> {    let d = if data.starts_with("0x") { unhex(data, "data")? } else { data.as_bytes().to_vec() };    Ok(format!("0x{}", hex::encode(keccak(&d))))}fn address_of(key: &VerifyingKey) -> String {    let p = key.to_encoded_point(false);    let h = keccak(&p.as_bytes()[1..]);    checksum(&hex::encode(&h[12..]))}/// The address of a public key in hex: 65 bytes starting 04, the 64 without it, or 33 compressed. A point not on the curve is an error value.pub fn eth_from_public_key(public_key: &str) -> Result<String, String> {    let mut k = unhex(public_key, "public key")?;    if k.len() == 64 {        k.insert(0, 4);    }    if !matches!(k.len(), 33 | 65) {        return Err(format!("a public key is 33, 64 or 65 bytes; this is {}", k.len()));    }    let key = VerifyingKey::from_sec1_bytes(&k).map_err(|_| "that public key is not a point on secp256k1".to_string())?;    Ok(address_of(&key))}fn personal_hash(message: &[u8]) -> [u8; 32] {    let mut d = format!("\x19Ethereum Signed Message:\n{}", message.len()).into_bytes();    d.extend_from_slice(message);    keccak(&d)}fn msg_bytes(message: &str) -> Result<Vec<u8>, String> {    if message.starts_with("0x") && message.len() % 2 == 0 && message[2..].bytes().all(|b| b.is_ascii_hexdigit()) && message.len() > 2 {        return unhex(message, "message");    }    Ok(message.as_bytes().to_vec())}/// The hash personal_sign signs: keccak of "\x19Ethereum Signed Message:\n", the length in bytes, and the message. A message of `0x` hex is taken as those bytes, as wallets do.pub fn eth_personal_hash(message: &str) -> Result<String, String> {    Ok(format!("0x{}", hex::encode(personal_hash(&msg_bytes(message)?))))}struct Parts {    sig: Signature,    recovery: u8,    high_s: bool,}fn parts(signature: &str) -> Result<Parts, String> {    let b = unhex(signature, "signature")?;    if b.len() != 65 {        return Err(format!("a signature is 65 bytes (r, s, v); this is {}", b.len()));    }    let v = match b[64] {        0 | 1 => b[64],        27 | 28 => b[64] - 27,        x => return Err(format!("v is {}; personal_sign gives 27 or 28 (or 0 or 1)", x)),    };    let sig = Signature::from_slice(&b[..64]).map_err(|_| "r or s is out of range for secp256k1".to_string())?;    // A high s is the same signature's twin (EIP-2): recover with it normalized and the odd bit flipped.    let (sig, recovery, high_s) = match sig.normalize_s() {        Some(low) => (low, v ^ 1, true),        None => (sig, v, false),    };    Ok(Parts { sig, recovery, high_s })}fn recover(hash: &[u8; 32], p: &Parts) -> Result<VerifyingKey, String> {    let id = RecoveryId::from_byte(p.recovery).ok_or("bad recovery id")?;    VerifyingKey::recover_from_prehash(hash, &p.sig, id).map_err(|_| "no public key fits that signature".to_string())}/// Who signed a message with personal_sign: { address, public_key, hash, high_s }. `high_s` true means a signature Ethereum transactions refuse under EIP-2, though recovery still gives an address.pub fn eth_recover(message: &str, signature: &str) -> Result<Value, String> {    let p = parts(signature)?;    let hash = personal_hash(&msg_bytes(message)?);    let key = recover(&hash, &p)?;    Ok(json!({        "address": address_of(&key),        "public_key": format!("0x{}", hex::encode(key.to_encoded_point(false).as_bytes())),        "hash": format!("0x{}", hex::encode(hash)),        "high_s": p.high_s,    }))}/// Who signed a 32-byte hash, for signatures not made by personal_sign (EIP-712 digests, for instance): gives the address.pub fn eth_recover_hash(hash: &str, signature: &str) -> Result<String, String> {    let h: [u8; 32] = unhex(hash, "hash")?.try_into().map_err(|_| "a hash is 32 bytes".to_string())?;    Ok(address_of(&recover(&h, &parts(signature)?)?))}/// Whether a message was signed by an address, for "sign in with a wallet". false for someone else's signature; an error value only when the address or signature is malformed.pub fn eth_verify(message: &str, signature: &str, address: &str) -> Result<bool, String> {    let want = checksum(&body(address)?.to_lowercase());    let p = parts(signature)?;    Ok(match recover(&personal_hash(&msg_bytes(message)?), &p) {        Ok(k) => !p.high_s && address_of(&k) == want,        Err(_) => false,    })}fn signing_key(private_key: &str) -> Result<SigningKey, String> {    let k = unhex(private_key, "private key")?;    SigningKey::from_slice(&k).map_err(|_| "a private key is 32 bytes, from 1 to the order of secp256k1".to_string())}/// A message signed the personal_sign way, with deterministic RFC 6979 nonces: { signature, address, r, s, v }. Read the key from env, never from a request.pub fn eth_sign(message: &str, private_key: &str) -> Result<Value, String> {    let key = signing_key(private_key)?;    let hash = personal_hash(&msg_bytes(message)?);    let (sig, id) = key.sign_prehash_recoverable(&hash).map_err(|e| e.to_string())?;    let v = 27 + id.to_byte();    let mut raw = sig.to_bytes().to_vec();    raw.push(v);    Ok(json!({        "signature": format!("0x{}", hex::encode(&raw)),        "address": address_of(key.verifying_key()),        "r": format!("0x{}", hex::encode(&raw[..32])),        "s": format!("0x{}", hex::encode(&raw[32..64])),        "v": v,    }))}fn pct_decode(s: &str) -> String {    let b = s.as_bytes();    let (mut out, mut i) = (vec![], 0);    while i < b.len() {        match s.get(i + 1..i + 3).filter(|_| b[i] == b'%').and_then(|x| u8::from_str_radix(x, 16).ok()) {            Some(h) => {                out.push(h);                i += 3;            }            None => {                out.push(b[i]);                i += 1;            }        }    }    String::from_utf8_lossy(&out).into_owned()}// EIP-681 numbers: decimal with an optional exponent, "2.014e18", or hex "0x...", all exact.fn number(text: &str) -> Result<String, String> {    let t = text.trim();    if let Some(h) = t.strip_prefix("0x") {        if h.is_empty() || !h.bytes().all(|b| b.is_ascii_hexdigit()) {            return Err(format!("`{}` is not a number", text));        }        let mut digits = vec![0u8];        for c in h.chars() {            let mut carry = c.to_digit(16).unwrap();            for d in digits.iter_mut() {                let v = *d as u32 * 16 + carry;                *d = (v % 10) as u8;                carry = v / 10;            }            while carry > 0 {                digits.push((carry % 10) as u8);                carry /= 10;            }        }        return Ok(digits.iter().rev().map(|d| (d + 48) as char).collect());    }    let low = t.to_lowercase();    let (m, e) = low.split_once('e').unwrap_or((&low, "0"));    let e: i64 = e.parse().map_err(|_| format!("`{}` is not a number", text))?;    let (w, f) = m.split_once('.').unwrap_or((m, ""));    if w.is_empty() || !w.bytes().all(|b| b.is_ascii_digit()) || !f.bytes().all(|b| b.is_ascii_digit()) {        return Err(format!("`{}` is not a number", text));    }    let shift = e - f.len() as i64;    if shift.abs() > 100 {        return Err(format!("`{}` is out of range", text));    }    let all = format!("{}{}", w, f);    let mut s = if shift >= 0 {        all + &"0".repeat(shift as usize)    } else {        let cut = shift.unsigned_abs() as usize;        if cut >= all.len() || all[all.len() - cut..].bytes().any(|b| b != b'0') {            return Err(format!("`{}` is not a whole number of the smallest unit", text));        }        all[..all.len() - cut].to_string()    };    s = s.trim_start_matches('0').to_string();    Ok(if s.is_empty() { "0".into() } else { s })}fn target(t: &str) -> Result<Value, String> {    if t.starts_with("0x") || t.starts_with("0X") {        let c = eth_check(t);        if c["valid"] != json!(true) {            return Err(c["reason"].as_str().unwrap_or("bad address").to_string());        }        return Ok(c["address"].clone());    }    // An ENS name; a plugin cannot resolve one without a node, so it is handed over as it is.    if t.contains('.') && t.bytes().all(|b| b.is_ascii_alphanumeric() || b".-_".contains(&b)) {        return Ok(json!(t));    }    Err(format!("`{}` is neither an address nor an ENS name", t))}/// An EIP-681 `ethereum:` URI read: { prefix, target, chain_id, function, value, gas, gas_limit, gas_price, params, token_transfer }. `2.014e18` amounts become exact decimal text. For the ERC-20 `transfer` function, `token_transfer` has { token, to, amount }.pub fn eth_uri_parse(uri: &str) -> Result<Value, String> {    let u = uri.trim();    let rest = u.strip_prefix("ethereum:").ok_or("an EIP-681 URI starts with `ethereum:`")?;    let (main, query) = rest.split_once('?').unwrap_or((rest, ""));    let (main, function) = match main.split_once('/') {        Some((m, f)) => (m, Some(f)),        None => (main, None),    };    let (main, chain) = match main.rsplit_once('@') {        Some((m, c)) => (m, Some(c.parse::<u64>().map_err(|_| format!("the chain id `{}` is not a number", c))?)),        None => (main, None),    };    let (prefix, addr) = match main.split_once('-') {        Some((p, a)) if p.chars().all(|c| c.is_ascii_alphabetic()) && (a.starts_with("0x") || a.contains('.')) => (Some(p), a),        _ => (None, main),    };    let mut out = Map::new();    out.insert("prefix".into(), json!(prefix.unwrap_or("pay")));    out.insert("target".into(), target(addr)?);    out.insert("chain_id".into(), json!(chain.unwrap_or(1)));    out.insert("function".into(), json!(function));    for k in ["value", "gas", "gas_limit", "gas_price"] {        out.insert(k.into(), Value::Null);    }    let mut params = vec![];    for pair in query.split('&').filter(|p| !p.is_empty()) {        let (k, v) = pair.split_once('=').ok_or(format!("`{}` has no value", pair))?;        let v = pct_decode(v);        match k {            "value" => {                out.insert("value".into(), json!(number(&v)?));            }            "gas" | "gasLimit" | "gasPrice" => {                out.insert(match k { "gas" => "gas", "gasLimit" => "gas_limit", _ => "gas_price" }.into(), json!(number(&v)?));            }            _ => {                let val = if k.starts_with("uint") || k.starts_with("int") { json!(number(&v)?) } else if k == "address" { target(&v)? } else { json!(v) };                params.push(json!({ "type": k, "value": val }));            }        }    }    let transfer = if function == Some("transfer") {        let to = params.iter().find(|p| p["type"] == "address").map(|p| p["value"].clone());        let amount = params.iter().find(|p| p["type"] == "uint256").map(|p| p["value"].clone());        match (to, amount) {            (Some(to), Some(amount)) => json!({ "token": out["target"], "to": to, "amount": amount }),            _ => return Err("an ERC-20 `transfer` needs `address=` and `uint256=`".into()),        }    } else {        Value::Null    };    out.insert("params".into(), Value::Array(params));    out.insert("token_transfer".into(), transfer);    Ok(Value::Object(out))}/// An EIP-681 URI from { to, chain_id, value (wei as text) } for a plain payment, or { token, to, amount, chain_id } for an ERC-20 transfer. Addresses are written with their checksum.pub fn eth_uri_build(request: serde_json::Value) -> Result<String, String> {    let text = |k: &str| match request.get(k) {        Some(Value::String(s)) => Some(s.clone()),        Some(Value::Number(n)) => Some(n.to_string()),        _ => None,    };    let to = text("to").ok_or("the request needs `to`")?;    let to = target(&to)?;    let to = to.as_str().unwrap_or_default();    let chain = match request.get("chain_id").and_then(|v| v.as_u64()) {        Some(1) | None => String::new(),        Some(c) => format!("@{}", c),    };    if let Some(token) = text("token") {        let token = target(&token)?;        let amount = number(&text("amount").ok_or("an ERC-20 transfer needs `amount` in the token's smallest unit")?)?;        return Ok(format!("ethereum:{}{}/transfer?address={}&uint256={}", token.as_str().unwrap_or_default(), chain, to, amount));    }    let q = match text("value") {        Some(v) => format!("?value={}", number(&v)?),        None => String::new(),    };    Ok(format!("ethereum:{}{}{}", to, chain, q))}
Thread
25Useful? Like it, or add to its thread below.

Thread 0 posts

On Ethereum addresses: questions, tips, corrections, and code that uses it.

No posts yet. The first one opens the thread.