localharness 0.15.0

A Rust-native agent SDK for Gemini. Streaming, custom tools, safety policies, background triggers — zero external binaries.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
//! Subdomain-side companion to `signer.rs`.
//!
//! Embeds `https://localharness.xyz/?signer=1` in a hidden iframe,
//! sends a random nonce via postMessage, awaits the signed response,
//! recovers the signing address, and compares it against the on-chain
//! owner of this subdomain's name (from `registry::owner_of_name`).
//!
//! Returns:
//! - `Ok(VerifyResult::VerifiedOwner { address })` — visitor is the
//!   on-chain owner; unlock full UX.
//! - `Ok(VerifyResult::Visitor { owner_address })` — visitor signed
//!   with a different address; read-only mode.
//! - `Ok(VerifyResult::Unregistered)` — name has no on-chain owner
//!   yet; treat as a fresh subdomain.
//! - `Err(msg)` — RPC failed, iframe failed, signer didn't respond.
//!   Callers fall back to the legacy local-OPFS UUID model so the app
//!   keeps working even when the verification chain has a hiccup.

use std::cell::RefCell;
use std::rc::Rc;

use wasm_bindgen::prelude::*;
use wasm_bindgen::JsCast;
use wasm_bindgen_futures::JsFuture;
use web_sys::{HtmlIFrameElement, MessageEvent};

use crate::wallet;

const SIGNER_URL: &str = "https://localharness.xyz/?signer=1";
const SIGNER_ORIGIN: &str = "https://localharness.xyz";
/// How long to wait for the signer to reply before giving up.
const TIMEOUT_MS: u32 = 5_000;

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum VerifyResult {
    VerifiedOwner { address: String },
    Visitor {
        owner_address: String,
        /// Recovered address that signed the challenge — the visitor's
        /// master-wallet address. The payment flow uses it as the
        /// `from` of the on-chain payment tx so the iframe signer can
        /// query the correct nonce + gas-price + balance.
        visitor_address: String,
    },
    Unregistered,
}

/// Run the full verify flow for `name`. Idempotent and side-effect
/// free apart from creating + removing a hidden iframe.
pub(crate) async fn verify_owner(name: &str) -> Result<VerifyResult, String> {
    // 1. Who owns this name on-chain?
    let on_chain_owner = super::registry::owner_of_name(name).await?;
    let Some(expected) = on_chain_owner else {
        return Ok(VerifyResult::Unregistered);
    };

    // 2. Get a signature from the apex signer. The signer's `paint_signer`
    // is async — it might not have loaded the master wallet by the time
    // we post the first challenge. Retry once after a short backoff so a
    // simple race condition doesn't surface as "verify failed".
    let nonce = random_nonce();
    let nonce_hex = bytes_to_hex(&nonce);
    let (signer_address, signature) = match sign_via_iframe(&nonce_hex, name).await {
        Ok(pair) => pair,
        Err(first_err) => {
            sleep_ms(1500).await;
            sign_via_iframe(&nonce_hex, name).await
                .map_err(|second_err| format!(
                    "signer didn't respond — first attempt: {first_err}; retry: {second_err}"
                ))?
        }
    };

    // 3. Recover the signer address from the signature and verify it
    //    matches what the signer claimed (basic sanity check). The
    //    preimage binds the subdomain `name` so a signature proving
    //    ownership of one name can't be replayed as proof for a different
    //    name held by the same address. MUST match `signer.rs`
    //    `build_challenge_response` byte-for-byte.
    let prehash = challenge_prehash(name, &nonce);
    let sig_bytes = hex_to_bytes(&signature)?;
    if sig_bytes.len() != 65 {
        return Err(format!("bad signature length {}", sig_bytes.len()));
    }
    let mut sig_arr = [0u8; 65];
    sig_arr.copy_from_slice(&sig_bytes);
    let recovered = wallet::recover_address(&sig_arr, &prehash)?;
    let recovered_hex = format!("0x{}", bytes_to_hex(&recovered));

    if recovered_hex.to_lowercase() != signer_address.to_lowercase() {
        return Err(format!(
            "signature claimed address {signer_address} but recovered {recovered_hex}"
        ));
    }

    // 4. Compare against on-chain owner. Case-insensitive — addresses
    //    can come back checksummed-cased or all-lowercase from the RPC.
    if recovered_hex.to_lowercase() == expected.to_lowercase() {
        Ok(VerifyResult::VerifiedOwner {
            address: recovered_hex,
        })
    } else {
        Ok(VerifyResult::Visitor {
            owner_address: expected,
            visitor_address: recovered_hex,
        })
    }
}

/// keccak256("localharness-auth-v0:" || name || ":" || nonce). Binds the
/// owner-proof to BOTH the subdomain name and a random nonce, so a
/// signature proving ownership of one name can't be replayed as proof for
/// a different name held by the same address. MUST stay byte-for-byte
/// identical to `signer.rs::build_challenge_response`.
fn challenge_prehash(name: &str, nonce: &[u8]) -> [u8; 32] {
    use sha3::{Digest, Keccak256};
    let mut hasher = Keccak256::new();
    hasher.update(b"localharness-auth-v0:");
    hasher.update(name.as_bytes());
    hasher.update(b":");
    hasher.update(nonce);
    let mut out = [0u8; 32];
    out.copy_from_slice(&hasher.finalize());
    out
}

/// Embed the signer iframe, send a sign challenge, wait for the
/// reply. Cleans up the iframe before returning. `name` is the subdomain
/// being verified — sent so the signer binds it into the signed preimage.
async fn sign_via_iframe(nonce_hex: &str, name: &str) -> Result<(String, String), String> {
    let id = format!("verify-{}", random_id_hex());
    let payload = js_sys::Object::new();
    let _ = js_sys::Reflect::set(
        &payload,
        &JsValue::from_str("type"),
        &JsValue::from_str("lh-sign-challenge"),
    );
    let _ = js_sys::Reflect::set(&payload, &JsValue::from_str("id"), &JsValue::from_str(&id));
    let _ = js_sys::Reflect::set(
        &payload,
        &JsValue::from_str("nonce"),
        &JsValue::from_str(nonce_hex),
    );
    let _ = js_sys::Reflect::set(
        &payload,
        &JsValue::from_str("name"),
        &JsValue::from_str(name),
    );

    let data = signer_iframe_request(&id, &payload.into(), TIMEOUT_MS).await?;
    let address = js_sys::Reflect::get(&data, &JsValue::from_str("address"))
        .ok()
        .and_then(|v| v.as_string())
        .unwrap_or_default();
    let signature = js_sys::Reflect::get(&data, &JsValue::from_str("signature"))
        .ok()
        .and_then(|v| v.as_string())
        .unwrap_or_default();
    if address.is_empty() || signature.is_empty() {
        return Err("signer reply missing address or signature".into());
    }
    Ok((address, signature))
}

const TX_TIMEOUT_MS: u32 = 90_000;

/// Ask the apex signer to sign a sponsored Tempo tx with the master
/// wallet. Returns `(signer_address, 65-byte signature)` over the tx's
/// sender_hash.
///
/// SECURITY: we send the tx's **structured fields** (chain id, fees,
/// nonce, fee token, and every call's `to`/`value`/`input`), not just an
/// opaque 32-byte digest. The apex signer reconstructs the sender_hash
/// from these, enforces a call-target allowlist (registry diamond + $LH
/// token, zero native value), and refuses to sign anything else — so a
/// hostile subdomain can no longer get the master wallet to sign an
/// arbitrary transaction (the confused-deputy fund-drain vector). The
/// `digest` is still sent as a cross-check; the caller re-verifies the
/// returned signature against its own `tx.sender_hash()` (fail-closed).
pub(crate) async fn sign_tempo_tx_via_iframe(
    tx: &crate::tempo_tx::TempoTx,
    purpose: &str,
) -> Result<(String, [u8; 65]), String> {
    let id = format!("digest-{}", random_id_hex());
    let digest = tx.sender_hash();
    let digest_hex = format!("0x{}", bytes_to_hex(&digest));

    let payload = js_sys::Object::new();
    let set_str = |obj: &js_sys::Object, k: &str, v: &str| {
        let _ = js_sys::Reflect::set(obj, &JsValue::from_str(k), &JsValue::from_str(v));
    };
    set_str(&payload, "type", "lh-sign-digest");
    set_str(&payload, "id", &id);
    set_str(&payload, "digest", &digest_hex);
    set_str(&payload, "purpose", purpose);

    // Structured fields the signer reconstructs + validates against.
    let txo = js_sys::Object::new();
    let _ = js_sys::Reflect::set(
        &txo,
        &JsValue::from_str("chainId"),
        &JsValue::from_f64(tx.chain_id as f64),
    );
    set_str(&txo, "maxPriorityFeePerGas", &format!("0x{:x}", tx.max_priority_fee_per_gas));
    set_str(&txo, "maxFeePerGas", &format!("0x{:x}", tx.max_fee_per_gas));
    set_str(&txo, "gasLimit", &format!("0x{:x}", tx.gas_limit));
    set_str(&txo, "nonce", &format!("0x{:x}", tx.nonce));
    match tx.fee_token {
        Some(addr) => set_str(&txo, "feeToken", &format!("0x{}", bytes_to_hex(&addr))),
        None => {
            let _ = js_sys::Reflect::set(&txo, &JsValue::from_str("feeToken"), &JsValue::NULL);
        }
    }
    let _ = js_sys::Reflect::set(
        &txo,
        &JsValue::from_str("sponsored"),
        &JsValue::from_bool(tx.sponsored),
    );
    let calls = js_sys::Array::new();
    for c in &tx.calls {
        let co = js_sys::Object::new();
        set_str(&co, "to", &format!("0x{}", bytes_to_hex(&c.to)));
        set_str(&co, "value", &format!("0x{:x}", c.value_wei));
        set_str(&co, "input", &format!("0x{}", bytes_to_hex(&c.input)));
        calls.push(&co);
    }
    let _ = js_sys::Reflect::set(&txo, &JsValue::from_str("calls"), &calls);
    let _ = js_sys::Reflect::set(&payload, &JsValue::from_str("tx"), &txo);

    let data = signer_iframe_request(&id, &payload.into(), TX_TIMEOUT_MS).await?;
    let address = js_sys::Reflect::get(&data, &JsValue::from_str("address"))
        .ok()
        .and_then(|v| v.as_string())
        .unwrap_or_default();
    let sig_hex = js_sys::Reflect::get(&data, &JsValue::from_str("signature"))
        .ok()
        .and_then(|v| v.as_string())
        .unwrap_or_default();
    if address.is_empty() || sig_hex.is_empty() {
        return Err("signer reply missing address or signature".into());
    }
    let sig_bytes = hex_to_bytes(&sig_hex)?;
    if sig_bytes.len() != 65 {
        return Err(format!("signature must be 65 bytes, got {}", sig_bytes.len()));
    }
    let mut sig = [0u8; 65];
    sig.copy_from_slice(&sig_bytes);
    Ok((address, sig))
}

/// Generous timeout for OPFS-touching ops at apex (create wallet,
/// import seed). The actual work is a single file write; the budget
/// is generous to absorb wasm-bundle cold-load and a slow disk.
const IDENTITY_TIMEOUT_MS: u32 = 20_000;

/// Ask the apex signer for the cached mnemonic. Returns the 12-word
/// phrase on success, or an error if no identity exists at apex.
pub(crate) async fn reveal_seed_via_iframe() -> Result<String, String> {
    let id = format!("reveal-{}", random_id_hex());
    let payload = js_sys::Object::new();
    let _ = js_sys::Reflect::set(
        &payload,
        &JsValue::from_str("type"),
        &JsValue::from_str("lh-reveal-seed"),
    );
    let _ = js_sys::Reflect::set(&payload, &JsValue::from_str("id"), &JsValue::from_str(&id));
    let data = signer_iframe_request(&id, &payload.into(), TIMEOUT_MS).await?;
    let phrase = js_sys::Reflect::get(&data, &JsValue::from_str("phrase"))
        .ok()
        .and_then(|v| v.as_string())
        .unwrap_or_default();
    if phrase.is_empty() {
        return Err("signer reply missing phrase".into());
    }
    Ok(phrase)
}

/// Ensure the apex origin has a master wallet, returning its address.
/// If `overwrite` is false (the default in tenant-side flows), an
/// existing wallet is preserved — only a brand-new origin gets a fresh
/// keypair. Pass `overwrite=true` from the explicit apex "create
/// identity" path where the user is asking for a fresh wallet.
pub(crate) async fn create_wallet_via_iframe(overwrite: bool) -> Result<String, String> {
    let id = format!("create-{}", random_id_hex());
    let payload = js_sys::Object::new();
    let _ = js_sys::Reflect::set(
        &payload,
        &JsValue::from_str("type"),
        &JsValue::from_str("lh-create-wallet"),
    );
    let _ = js_sys::Reflect::set(&payload, &JsValue::from_str("id"), &JsValue::from_str(&id));
    let _ = js_sys::Reflect::set(
        &payload,
        &JsValue::from_str("overwrite"),
        &JsValue::from_bool(overwrite),
    );
    let data = signer_iframe_request(&id, &payload.into(), IDENTITY_TIMEOUT_MS).await?;
    let address = js_sys::Reflect::get(&data, &JsValue::from_str("address"))
        .ok()
        .and_then(|v| v.as_string())
        .unwrap_or_default();
    if address.is_empty() {
        return Err("signer reply missing address".into());
    }
    Ok(address)
}

/// Run the full apex claim flow (faucet → register → wait receipt) from
/// the apex signer iframe. Long timeout because waiting for a receipt
/// can take ~10s and the faucet drip adds another ~5s. Returns
/// `(owner_address, tx_hash)`.
pub(crate) async fn claim_name_via_iframe(name: &str) -> Result<(String, String), String> {
    let id = format!("claim-{}", random_id_hex());
    let payload = js_sys::Object::new();
    let _ = js_sys::Reflect::set(
        &payload,
        &JsValue::from_str("type"),
        &JsValue::from_str("lh-claim-name"),
    );
    let _ = js_sys::Reflect::set(&payload, &JsValue::from_str("id"), &JsValue::from_str(&id));
    let _ = js_sys::Reflect::set(
        &payload,
        &JsValue::from_str("name"),
        &JsValue::from_str(name),
    );
    let data = signer_iframe_request(&id, &payload.into(), CLAIM_TIMEOUT_MS).await?;
    let address = js_sys::Reflect::get(&data, &JsValue::from_str("address"))
        .ok()
        .and_then(|v| v.as_string())
        .unwrap_or_default();
    let tx_hash = js_sys::Reflect::get(&data, &JsValue::from_str("tx_hash"))
        .ok()
        .and_then(|v| v.as_string())
        .unwrap_or_default();
    if address.is_empty() || tx_hash.is_empty() {
        return Err("signer reply missing address or tx_hash".into());
    }
    Ok((address, tx_hash))
}

const CLAIM_TIMEOUT_MS: u32 = 90_000;

/// Ask the apex signer to seal `plaintext` (the Gemini key) with the
/// seed-derived key. Returns ciphertext hex for on-chain storage.
pub(crate) async fn seal_key_via_iframe(plaintext: &str) -> Result<String, String> {
    let id = format!("seal-{}", random_id_hex());
    let payload = js_sys::Object::new();
    let _ = js_sys::Reflect::set(
        &payload,
        &JsValue::from_str("type"),
        &JsValue::from_str("lh-seal-key"),
    );
    let _ = js_sys::Reflect::set(&payload, &JsValue::from_str("id"), &JsValue::from_str(&id));
    let _ = js_sys::Reflect::set(
        &payload,
        &JsValue::from_str("plaintext"),
        &JsValue::from_str(plaintext),
    );
    let data = signer_iframe_request(&id, &payload.into(), IDENTITY_TIMEOUT_MS).await?;
    js_sys::Reflect::get(&data, &JsValue::from_str("ciphertext"))
        .ok()
        .and_then(|v| v.as_string())
        .filter(|s| !s.is_empty())
        .ok_or_else(|| "signer reply missing ciphertext".to_string())
}

/// Ask the apex signer to open seed-sealed `ciphertext_hex` → plaintext.
pub(crate) async fn open_key_via_iframe(ciphertext_hex: &str) -> Result<String, String> {
    let id = format!("open-{}", random_id_hex());
    let payload = js_sys::Object::new();
    let _ = js_sys::Reflect::set(
        &payload,
        &JsValue::from_str("type"),
        &JsValue::from_str("lh-open-key"),
    );
    let _ = js_sys::Reflect::set(&payload, &JsValue::from_str("id"), &JsValue::from_str(&id));
    let _ = js_sys::Reflect::set(
        &payload,
        &JsValue::from_str("ciphertext"),
        &JsValue::from_str(ciphertext_hex),
    );
    let data = signer_iframe_request(&id, &payload.into(), IDENTITY_TIMEOUT_MS).await?;
    js_sys::Reflect::get(&data, &JsValue::from_str("plaintext"))
        .ok()
        .and_then(|v| v.as_string())
        .filter(|s| !s.is_empty())
        .ok_or_else(|| "signer reply missing plaintext".to_string())
}

/// Ask the apex signer to import a user-supplied seed phrase and
/// persist it. Returns the new address. Overwrites any existing wallet.
pub(crate) async fn import_seed_via_iframe(phrase: &str) -> Result<String, String> {
    let id = format!("import-{}", random_id_hex());
    let payload = js_sys::Object::new();
    let _ = js_sys::Reflect::set(
        &payload,
        &JsValue::from_str("type"),
        &JsValue::from_str("lh-import-seed"),
    );
    let _ = js_sys::Reflect::set(&payload, &JsValue::from_str("id"), &JsValue::from_str(&id));
    let _ = js_sys::Reflect::set(
        &payload,
        &JsValue::from_str("phrase"),
        &JsValue::from_str(phrase),
    );
    let data = signer_iframe_request(&id, &payload.into(), IDENTITY_TIMEOUT_MS).await?;
    let address = js_sys::Reflect::get(&data, &JsValue::from_str("address"))
        .ok()
        .and_then(|v| v.as_string())
        .unwrap_or_default();
    if address.is_empty() {
        return Err("signer reply missing address".into());
    }
    Ok(address)
}

/// How long to wait for the signer iframe's `lh-signer-ready` ping
/// before posting the challenge anyway. The wasm bundle in a cold
/// iframe can take a couple of seconds to compile + install its
/// postMessage listener; this window covers that. If the ready ping
/// never arrives we post anyway as best-effort.
const READY_TIMEOUT_MS: u32 = 15_000;

/// Shared iframe-lifecycle dance — load the apex signer in a hidden
/// iframe, attach a correlation-id-filtered listener, wait for the
/// `lh-signer-ready` ping, post `payload`, race the reply against
/// `timeout_ms`, tear down. Returns the raw response `JsValue` (a
/// `{type:"lh-sign-response", id, ...}` object); callers parse the
/// variant-specific fields.
async fn signer_iframe_request(
    expected_id: &str,
    payload: &JsValue,
    timeout_ms: u32,
) -> Result<JsValue, String> {
    let doc = super::dom::document().map_err(|e| format!("document: {e:?}"))?;
    let body = doc.body().ok_or_else(|| "no body".to_string())?;

    let iframe: HtmlIFrameElement = doc
        .create_element("iframe")
        .map_err(|e| format!("iframe: {e:?}"))?
        .dyn_into()
        .map_err(|_| "not an iframe".to_string())?;
    iframe.set_src(SIGNER_URL);
    let _ = iframe.set_attribute(
        "style",
        "display:none;width:0;height:0;border:0;position:absolute;",
    );
    body.append_child(&iframe)
        .map_err(|e| format!("append iframe: {e:?}"))?;

    let result_slot: Rc<RefCell<Option<Result<JsValue, String>>>> =
        Rc::new(RefCell::new(None));
    let waker_slot: Rc<RefCell<Option<js_sys::Function>>> = Rc::new(RefCell::new(None));
    // Separate ready slot: signer posts `{type:"lh-signer-ready"}` once
    // its postMessage listener is installed. Verify-side gates on this
    // instead of a fixed sleep, so a slow wasm-compile doesn't race.
    let ready_slot: Rc<RefCell<bool>> = Rc::new(RefCell::new(false));
    let ready_waker: Rc<RefCell<Option<js_sys::Function>>> = Rc::new(RefCell::new(None));

    let result_for_handler = result_slot.clone();
    let waker_for_handler = waker_slot.clone();
    let ready_for_handler = ready_slot.clone();
    let ready_waker_for_handler = ready_waker.clone();
    let id_for_handler = expected_id.to_string();
    let handler = Closure::<dyn FnMut(_)>::new(move |event: MessageEvent| {
        let data = event.data();
        if data.is_null() || data.is_undefined() {
            return;
        }
        if event.origin() != SIGNER_ORIGIN {
            return;
        }
        let msg_type = js_sys::Reflect::get(&data, &JsValue::from_str("type"))
            .ok()
            .and_then(|v| v.as_string())
            .unwrap_or_default();

        if msg_type == "lh-signer-ready" {
            *ready_for_handler.borrow_mut() = true;
            if let Some(waker) = ready_waker_for_handler.borrow_mut().take() {
                let _ = waker.call0(&JsValue::NULL);
            }
            return;
        }

        if msg_type != "lh-sign-response" {
            return;
        }
        let id_match = js_sys::Reflect::get(&data, &JsValue::from_str("id"))
            .ok()
            .and_then(|v| v.as_string())
            .unwrap_or_default();
        if id_match != id_for_handler {
            return;
        }
        let outcome = if let Some(err) = js_sys::Reflect::get(&data, &JsValue::from_str("error"))
            .ok()
            .and_then(|v| v.as_string())
        {
            Err(format!("signer: {err}"))
        } else {
            Ok(data.clone())
        };
        *result_for_handler.borrow_mut() = Some(outcome);
        if let Some(waker) = waker_for_handler.borrow_mut().take() {
            let _ = waker.call0(&JsValue::NULL);
        }
    });
    let window = super::dom::window().map_err(|e| format!("window: {e:?}"))?;
    window
        .add_event_listener_with_callback("message", handler.as_ref().unchecked_ref())
        .map_err(|e| format!("add listener: {e:?}"))?;

    // Wait for the iframe content_window to materialize.
    let mut content_window: Option<web_sys::Window> = None;
    for _ in 0..50 {
        if let Some(w) = iframe.content_window() {
            content_window = Some(w);
            break;
        }
        sleep_ms(50).await;
    }
    let target = content_window
        .ok_or_else(|| "iframe content window never available".to_string())?;

    // Wait for the signer to send its `lh-signer-ready` ping (set by
    // paint_signer once the wasm bundle has compiled + the listener
    // is installed + the wallet is loaded-or-known-absent). Falls back
    // to posting anyway after READY_TIMEOUT_MS so a missing ping
    // doesn't deadlock — though every shipped signer paints one.
    if !*ready_slot.borrow() {
        let ready_promise = js_sys::Promise::new(&mut |resolve, _reject| {
            *ready_waker.borrow_mut() = Some(resolve.clone());
            if let Some(window) = web_sys::window() {
                let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(
                    &resolve,
                    READY_TIMEOUT_MS as i32,
                );
            }
        });
        let _ = JsFuture::from(ready_promise).await;
    }

    target
        .post_message(payload, SIGNER_ORIGIN)
        .map_err(|e| format!("postMessage: {e:?}"))?;

    let promise = js_sys::Promise::new(&mut |resolve, _reject| {
        let resolve_clone = resolve.clone();
        *waker_slot.borrow_mut() = Some(resolve_clone);
        if let Some(window) = web_sys::window() {
            let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(
                &resolve,
                timeout_ms as i32,
            );
        }
    });
    let _ = JsFuture::from(promise).await;

    let _ = window.remove_event_listener_with_callback(
        "message",
        handler.as_ref().unchecked_ref(),
    );
    drop(handler);
    let _ = body.remove_child(&iframe);

    result_slot
        .borrow_mut()
        .take()
        .unwrap_or_else(|| Err("signer did not respond within timeout".into()))
}

fn random_nonce() -> [u8; 32] {
    use rand_core::RngCore;
    let mut bytes = [0u8; 32];
    rand_core::OsRng.fill_bytes(&mut bytes);
    bytes
}

fn random_id_hex() -> String {
    use rand_core::RngCore;
    let mut bytes = [0u8; 8];
    rand_core::OsRng.fill_bytes(&mut bytes);
    bytes_to_hex(&bytes)
}

fn bytes_to_hex(bytes: &[u8]) -> String {
    let mut s = String::with_capacity(bytes.len() * 2);
    for b in bytes {
        s.push_str(&format!("{b:02x}"));
    }
    s
}

fn hex_to_bytes(hex: &str) -> Result<Vec<u8>, String> {
    let trimmed = hex.trim().trim_start_matches("0x").trim_start_matches("0X");
    if trimmed.len() % 2 != 0 {
        return Err("hex odd length".into());
    }
    let mut out = Vec::with_capacity(trimmed.len() / 2);
    let bytes = trimmed.as_bytes();
    let mut i = 0;
    while i < bytes.len() {
        let hi = match bytes[i] {
            b'0'..=b'9' => bytes[i] - b'0',
            b'a'..=b'f' => bytes[i] - b'a' + 10,
            b'A'..=b'F' => bytes[i] - b'A' + 10,
            _ => return Err(format!("non-hex byte {}", bytes[i])),
        };
        let lo = match bytes[i + 1] {
            b'0'..=b'9' => bytes[i + 1] - b'0',
            b'a'..=b'f' => bytes[i + 1] - b'a' + 10,
            b'A'..=b'F' => bytes[i + 1] - b'A' + 10,
            _ => return Err(format!("non-hex byte {}", bytes[i + 1])),
        };
        out.push((hi << 4) | lo);
        i += 2;
    }
    Ok(out)
}

async fn sleep_ms(ms: u32) {
    let promise = js_sys::Promise::new(&mut |resolve, _| {
        if let Some(window) = web_sys::window() {
            let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(
                &resolve,
                ms as i32,
            );
        }
    });
    let _ = JsFuture::from(promise).await;
}