use wasm_bindgen::prelude::*;
use wasm_bindgen::JsCast;
use web_sys::MessageEvent;
use crate::encoding::{bytes_to_hex_str, hex_to_bytes, parse_hex_quantity};
use crate::wallet;
use super::signer_protocol::{
challenge_prehash, MSG_CLAIM_NAME, MSG_CREATE_WALLET, MSG_IMPORT_SEED, MSG_OPEN_KEY,
MSG_REVEAL_SEED, MSG_SEAL_KEY, MSG_SIGN_CHALLENGE, MSG_SIGN_DIGEST, MSG_SIGN_RESPONSE,
};
pub(crate) fn install_signer_listener() -> Result<(), JsValue> {
let window = web_sys::window().ok_or_else(|| JsValue::from_str("no window"))?;
let handler = Closure::<dyn FnMut(_)>::new(move |event: MessageEvent| {
if let Err(err) = handle_message(&event) {
web_sys::console::warn_1(&JsValue::from_str(&format!("signer: {err}")));
}
});
window.add_event_listener_with_callback("message", handler.as_ref().unchecked_ref())?;
handler.forget();
Ok(())
}
fn handle_message(event: &MessageEvent) -> Result<(), String> {
let data = event.data();
if data.is_null() || data.is_undefined() {
return Ok(());
}
let msg_type = js_sys::Reflect::get(&data, &JsValue::from_str("type"))
.ok()
.and_then(|v| v.as_string())
.unwrap_or_default();
if !matches!(
msg_type.as_str(),
MSG_SIGN_CHALLENGE
| MSG_SIGN_DIGEST
| MSG_CREATE_WALLET
| MSG_REVEAL_SEED
| MSG_IMPORT_SEED
| MSG_CLAIM_NAME
| MSG_SEAL_KEY
| MSG_OPEN_KEY
) {
return Ok(());
}
let origin = event.origin();
if !is_trusted_origin(&origin) {
return Err(format!("untrusted origin: {origin}"));
}
let id = js_sys::Reflect::get(&data, &JsValue::from_str("id"))
.ok()
.and_then(|v| v.as_string())
.unwrap_or_default();
let source = event
.source()
.ok_or_else(|| "no source window on the message event".to_string())?;
let source_jsval: JsValue = source.into();
let get_str = |k: &str| {
js_sys::Reflect::get(&data, &JsValue::from_str(k))
.ok()
.and_then(|v| v.as_string())
};
let require_str =
|k: &str| get_str(k).ok_or_else(|| format!("{k} not a string"));
let reply = match msg_type.as_str() {
MSG_SIGN_CHALLENGE => {
let nonce_hex = require_str("nonce")?;
let name = get_str("name").unwrap_or_default();
match build_challenge_response(&id, &nonce_hex, &name) {
Ok(obj) => obj,
Err(err) => error_response(&id, &err),
}
}
MSG_SIGN_DIGEST => {
let purpose = get_str("purpose").unwrap_or_else(|| "sign digest".into());
spawn_reply(
"sign-digest",
id,
source_jsval,
origin.clone(),
build_sponsored_tx_response(data.clone(), purpose, origin),
);
return Ok(());
}
MSG_REVEAL_SEED => {
if !super::tenant::is_apex_origin(&origin) {
error_response(&id, "seed reveal is only available at localharness.xyz")
} else {
match build_reveal_seed_response(&id) {
Ok(obj) => obj,
Err(err) => error_response(&id, &err),
}
}
}
MSG_CREATE_WALLET => {
let overwrite = js_sys::Reflect::get(&data, &JsValue::from_str("overwrite"))
.ok()
.and_then(|v| v.as_bool())
.unwrap_or(false);
if overwrite && !super::tenant::is_apex_origin(&origin) {
error_response(
&id,
"creating a fresh identity is only available at localharness.xyz",
)
} else {
spawn_reply("create-wallet", id, source_jsval, origin, run_create_wallet(overwrite));
return Ok(());
}
}
MSG_IMPORT_SEED => {
if !super::tenant::is_apex_origin(&origin) {
error_response(&id, "seed import is only available at localharness.xyz")
} else {
let phrase = require_str("phrase")?;
spawn_reply("import-seed", id, source_jsval, origin, run_import_seed(phrase));
return Ok(());
}
}
MSG_CLAIM_NAME => {
let name = require_str("name")?;
spawn_reply("claim-name", id, source_jsval, origin, run_claim_name_op(name));
return Ok(());
}
MSG_SEAL_KEY => {
let plaintext = require_str("plaintext")?;
spawn_reply("seal-key", id, source_jsval, origin, run_seal_key(plaintext));
return Ok(());
}
MSG_OPEN_KEY => {
let ciphertext = require_str("ciphertext")?;
spawn_reply("open-key", id, source_jsval, origin, run_open_key(ciphertext));
return Ok(());
}
_ => return Ok(()), };
post_reply(&source_jsval, &reply, &origin)?;
Ok(())
}
fn post_reply(source: &JsValue, reply: &JsValue, origin: &str) -> Result<(), String> {
let post_msg = js_sys::Reflect::get(source, &JsValue::from_str("postMessage"))
.map_err(|_| "source has no postMessage".to_string())?;
let post_fn: js_sys::Function = post_msg
.dyn_into()
.map_err(|_| "source.postMessage isn't a function".to_string())?;
post_fn
.call2(source, reply, &JsValue::from_str(origin))
.map_err(|e| format!("postMessage call: {e:?}"))?;
Ok(())
}
type ReplyFields = Vec<(&'static str, String)>;
fn spawn_reply<F>(name: &'static str, id: String, source: JsValue, origin: String, op: F)
where
F: std::future::Future<Output = Result<ReplyFields, String>> + 'static,
{
wasm_bindgen_futures::spawn_local(async move {
let reply = match op.await {
Ok(fields) => success_response(&id, &fields),
Err(err) => error_response(&id, &err),
};
if let Err(err) = post_reply(&source, &reply, &origin) {
web_sys::console::warn_1(&JsValue::from_str(&format!(
"signer: {name} reply: {err}"
)));
}
});
}
fn success_response(id: &str, fields: &[(&'static str, String)]) -> JsValue {
let obj = js_sys::Object::new();
set(&obj, "type", JsValue::from_str(MSG_SIGN_RESPONSE));
set(&obj, "id", JsValue::from_str(id));
for (k, v) in fields {
set(&obj, k, JsValue::from_str(v));
}
JsValue::from(obj)
}
fn build_reveal_seed_response(id: &str) -> Result<JsValue, String> {
let phrase = super::APP
.with(|cell| {
cell.borrow()
.wallet
.as_ref()
.map(|w| w.mnemonic.to_string())
})
.ok_or_else(|| "no identity on this device".to_string())?;
Ok(success_response(id, &[("phrase", phrase)]))
}
async fn run_create_wallet(overwrite: bool) -> Result<ReplyFields, String> {
if !overwrite {
let existing = super::APP
.with(|cell| cell.borrow().wallet.as_ref().map(|w| w.address_hex()));
if let Some(addr) = existing {
return Ok(vec![("address", addr)]);
}
}
let wallet = super::wallet_store::create_and_persist().await?;
let addr = wallet.address_hex();
super::APP.with(|cell| cell.borrow_mut().wallet = Some(wallet));
Ok(vec![("address", addr)])
}
fn seed_sync_key() -> Result<[u8; 32], String> {
let entropy = super::APP
.with(|cell| cell.borrow().wallet.as_ref().map(|w| w.mnemonic.to_entropy()))
.ok_or_else(|| "no identity on this device".to_string())?;
Ok(super::encryption::keysync_key_from_entropy(&entropy))
}
async fn run_seal_key(plaintext: String) -> Result<ReplyFields, String> {
let key = seed_sync_key()?;
let ct = super::encryption::seal_with_raw_key(&key, plaintext.as_bytes())
.await
.ok_or_else(|| "seal failed".to_string())?;
Ok(vec![("ciphertext", bytes_to_hex_str(&ct))])
}
async fn run_open_key(ciphertext_hex: String) -> Result<ReplyFields, String> {
let key = seed_sync_key()?;
let ct = hex_to_bytes(&ciphertext_hex)?;
let pt = super::encryption::open_with_raw_key(&key, &ct)
.await
.ok_or_else(|| "open failed (wrong seed?)".to_string())?;
let s = String::from_utf8(pt).map_err(|_| "decrypted value not utf-8".to_string())?;
Ok(vec![("plaintext", s)])
}
async fn run_claim_name_op(name: String) -> Result<ReplyFields, String> {
let (address_hex, tx_hash) = run_claim_name(&name).await?;
Ok(vec![("address", address_hex), ("tx_hash", tx_hash)])
}
async fn run_claim_name(name: &str) -> Result<(String, String), String> {
let (signer, address) = wallet_handle()?;
let address_hex = bytes_to_hex_str(&address);
let fee_payer = super::sponsor::signer()?;
let tx_hash = crate::registry::claim_and_maybe_set_main_sponsored(
&signer,
&fee_payer,
name,
crate::registry::ALPHA_USD_ADDRESS,
)
.await?;
Ok((address_hex, tx_hash))
}
async fn run_import_seed(phrase: String) -> Result<ReplyFields, String> {
let wallet = super::wallet_store::import(&phrase).await?;
let addr = wallet.address_hex();
super::APP.with(|cell| cell.borrow_mut().wallet = Some(wallet));
Ok(vec![("address", addr)])
}
fn build_challenge_response(id: &str, nonce_hex: &str, name: &str) -> Result<JsValue, String> {
let nonce = hex_to_bytes(nonce_hex)?;
let prehash = challenge_prehash(name, &nonce);
let (signer, address) = wallet_handle()?;
let signature = wallet::sign_hash(&signer, &prehash);
Ok(success_response(
id,
&[
("address", bytes_to_hex_str(&address)),
("signature", bytes_to_hex_str(&signature)),
],
))
}
async fn build_sponsored_tx_response(
data: JsValue,
purpose: String,
origin: String,
) -> Result<ReplyFields, String> {
let tx_obj = js_sys::Reflect::get(&data, &JsValue::from_str("tx"))
.ok()
.filter(|v| !v.is_undefined() && !v.is_null())
.ok_or_else(|| "refusing to sign: missing structured tx fields".to_string())?;
let get = |k: &str| js_sys::Reflect::get(&tx_obj, &JsValue::from_str(k)).ok();
let get_str = |k: &str| get(k).and_then(|v| v.as_string());
let chain_id = get("chainId")
.and_then(|v| v.as_f64())
.map(|f| f as u64)
.ok_or_else(|| "tx.chainId missing".to_string())?;
if chain_id != crate::registry::CHAIN_ID {
return Err(format!("chainId {chain_id} != {}", crate::registry::CHAIN_ID));
}
let fee_priority =
parse_hex_quantity(&get_str("maxPriorityFeePerGas").ok_or("maxPriorityFeePerGas missing")?)?;
let fee_max = parse_hex_quantity(&get_str("maxFeePerGas").ok_or("maxFeePerGas missing")?)?;
let gas_limit = parse_hex_quantity(&get_str("gasLimit").ok_or("gasLimit missing")?)?;
let nonce = parse_hex_quantity(&get_str("nonce").ok_or("nonce missing")?)?;
let fee_token = match get_str("feeToken") {
Some(s) if !s.trim().trim_start_matches("0x").is_empty() => Some(parse_addr20(&s)?),
_ => None,
};
let sponsored = get("sponsored").and_then(|v| v.as_bool()).unwrap_or(false);
let registry_addr = parse_addr20(crate::registry::REGISTRY_ADDRESS)?;
let token_addr = parse_addr20(crate::registry::LOCALHARNESS_TOKEN_ADDRESS)?;
let calls_val = get("calls").ok_or_else(|| "tx.calls missing".to_string())?;
let calls_arr: js_sys::Array = calls_val
.dyn_into()
.map_err(|_| "tx.calls not an array".to_string())?;
if calls_arr.length() == 0 {
return Err("tx.calls empty".into());
}
let mut needs_owner_check = false;
let mut calls = Vec::with_capacity(calls_arr.length() as usize);
for i in 0..calls_arr.length() {
let c = calls_arr.get(i);
let cto = js_sys::Reflect::get(&c, &JsValue::from_str("to"))
.ok()
.and_then(|v| v.as_string())
.ok_or_else(|| "call.to missing".to_string())?;
let cval = js_sys::Reflect::get(&c, &JsValue::from_str("value"))
.ok()
.and_then(|v| v.as_string())
.unwrap_or_else(|| "0x0".into());
let cinput = js_sys::Reflect::get(&c, &JsValue::from_str("input"))
.ok()
.and_then(|v| v.as_string())
.unwrap_or_default();
let to = parse_addr20(&cto)?;
if to != registry_addr && to != token_addr {
return Err(format!(
"refusing to sign: call target {} is not allowlisted",
bytes_to_hex_str(&to)
));
}
let value_wei = parse_hex_quantity(&cval)?;
if value_wei != 0 {
return Err("refusing to sign: native value transfer not permitted".into());
}
let input = if cinput.trim().trim_start_matches("0x").is_empty() {
Vec::new()
} else {
hex_to_bytes(&cinput)?
};
if to == token_addr {
match input.get(0..4) {
Some([0x23, 0xb8, 0x72, 0xdd]) => {
return Err("refusing to sign: $LH transferFrom is not permitted via the cross-origin signer".into());
}
Some([0x09, 0x5e, 0xa7, 0xb3]) => {
if input.get(16..36) != Some(registry_addr.as_slice()) {
return Err("refusing to sign: $LH approve must target the diamond".into());
}
}
Some([0xa9, 0x05, 0x9c, 0xbb]) => {
needs_owner_check = true;
}
_ => {}
}
}
calls.push(crate::tempo_tx::TempoCall { to, value_wei, input });
}
let mut builder = crate::tempo_tx::TempoTxBuilder::new(chain_id)
.max_priority_fee_per_gas(fee_priority)
.max_fee_per_gas(fee_max)
.gas_limit(gas_limit)
.nonce(nonce)
.calls(calls);
if let Some(ft) = fee_token {
builder = builder.fee_token(ft);
}
if sponsored {
builder = builder.sponsored();
}
let rebuilt = builder.build();
let sender_hash = rebuilt.sender_hash();
if let Some(claimed) = js_sys::Reflect::get(&data, &JsValue::from_str("digest"))
.ok()
.and_then(|v| v.as_string())
{
if let Ok(claimed_bytes) = hex_to_bytes(&claimed) {
if claimed_bytes.as_slice() != sender_hash {
return Err("provided digest does not match reconstructed sender_hash".into());
}
}
}
let (signer, address) = wallet_handle()?;
if needs_owner_check {
let sub = super::tenant::tenant_name_from_origin(&origin).ok_or_else(|| {
"refusing to sign a $LH transfer: request is not from a tenant subdomain".to_string()
})?;
match crate::registry::owner_of_name(&sub).await {
Ok(Some(owner)) if owner.eq_ignore_ascii_case(&bytes_to_hex_str(&address)) => {}
Ok(_) => {
return Err(format!(
"refusing to sign a $LH transfer for '{sub}': that subdomain is not owned by this identity"
));
}
Err(e) => return Err(format!("$LH transfer ownership check failed: {e}")),
}
}
web_sys::console::log_1(&JsValue::from_str(&format!(
"lh-sign-digest: signed reconstructed sponsored tx ({purpose}, {} allowlisted call(s))",
rebuilt.calls.len(),
)));
let sig = wallet::sign_hash(&signer, &sender_hash);
Ok(vec![
("address", bytes_to_hex_str(&address)),
("signature", bytes_to_hex_str(&sig)),
])
}
fn parse_addr20(s: &str) -> Result<[u8; 20], String> {
crate::encoding::parse_address(s.trim())
}
fn wallet_handle() -> Result<(k256::ecdsa::SigningKey, [u8; 20]), String> {
super::APP
.with(|cell| {
cell.borrow()
.wallet
.as_ref()
.map(|w| (w.signer.clone(), w.address))
})
.ok_or_else(|| "no identity on this device — create one at the apex".to_string())
}
fn error_response(id: &str, err: &str) -> JsValue {
let obj = js_sys::Object::new();
set(&obj, "type", JsValue::from_str(MSG_SIGN_RESPONSE));
set(&obj, "id", JsValue::from_str(id));
set(&obj, "error", JsValue::from_str(err));
JsValue::from(obj)
}
fn set(obj: &js_sys::Object, key: &str, value: JsValue) {
let _ = js_sys::Reflect::set(obj, &JsValue::from_str(key), &value);
}
fn is_trusted_origin(origin: &str) -> bool {
super::tenant::is_trusted_lh_origin(origin)
}