use wasm_bindgen::prelude::*;
use wasm_bindgen::JsCast;
use web_sys::{Document, Element, HtmlElement, KeyboardEvent, MouseEvent};
use crate::encoding::{bytes_to_hex_str, parse_address};
use crate::filesystem::Filesystem;
use super::dom;
use super::templates;
mod admin;
mod bounty;
mod claim;
mod credits;
mod devices;
mod governance;
mod guild;
mod key_sync;
mod layout;
mod public_face;
mod schedule;
mod subdomains;
pub(crate) use credits::{refresh_fund_banner, try_redeem_pending_invite};
pub(crate) use key_sync::{sync_local_key_to_main, try_auto_restore_gemini_key};
pub(crate) use subdomains::{run_batch_create_subdomains, run_bulk_release, run_release_subdomain};
#[derive(Debug, Clone)]
enum Action {
Send,
SyncDevices,
OpfsWipe,
OpfsWipeConfirm,
OpfsWipeCancel,
OpfsDelete(String),
OpfsCloseViewer,
OpfsNav(String),
OpfsOpen(String),
OpfsEdit(String),
OpfsSave(String),
ApexClaim,
ClaimOnChain,
RevealSeed,
HideSeed,
ImportSeed,
CreateIdentity,
ShowImport,
CancelImport,
HeaderAdminToggle,
HeaderAdminClose,
ShowAdminTab(String),
RevealSecurity,
HideSecurity,
ResetArm,
ResetConfirm,
ResetCancel,
PricingSave,
ToggleFiles,
ToggleTerminal,
ShowTab(String),
FeedbackSubmit,
PairCancel,
AddDevice,
AdoptDevice,
CreateNewClaim(String),
SavePrompt,
SaveToolAllowlist,
ResetToolAllowlist,
SaveApiKey,
ToggleDisplay,
StopTurn,
SetPublicFace(String),
SetModelAccess(String),
SetModel(String),
DownloadLocalModel,
RedeemCode,
RedeemBanner,
CreateInvite,
ScheduleJob,
CancelJob(String),
PostBounty,
ClaimBounty(String),
CreateGuild,
FundGuild(String),
LoadProposals,
ProposeMeasure,
Vote(String),
ExecuteProposal(String),
SaveX402Price,
UnlinkDevice(String),
UnlinkConfirm(String),
UnlinkCancel,
}
impl Action {
fn parse(name: &str, arg: Option<String>) -> Option<Action> {
Some(match name {
"send" => Action::Send,
"opfs-wipe" => Action::OpfsWipe,
"opfs-wipe-confirm" => Action::OpfsWipeConfirm,
"opfs-wipe-cancel" => Action::OpfsWipeCancel,
"opfs-delete" => Action::OpfsDelete(arg.unwrap_or_default()),
"opfs-close-viewer" => Action::OpfsCloseViewer,
"opfs-nav" => Action::OpfsNav(arg.unwrap_or_default()),
"opfs-open" => Action::OpfsOpen(arg.unwrap_or_default()),
"opfs-edit" => Action::OpfsEdit(arg.unwrap_or_default()),
"opfs-save" => Action::OpfsSave(arg.unwrap_or_default()),
"apex-claim" => Action::ApexClaim,
"claim-on-chain" => Action::ClaimOnChain,
"reveal-seed" => Action::RevealSeed,
"hide-seed" => Action::HideSeed,
"import-seed" => Action::ImportSeed,
"create-identity" => Action::CreateIdentity,
"show-import" => Action::ShowImport,
"cancel-import" => Action::CancelImport,
"header-admin-toggle" => Action::HeaderAdminToggle,
"header-admin-close" => Action::HeaderAdminClose,
"show-admin-tab" => Action::ShowAdminTab(arg.unwrap_or_default()),
"reveal-security" => Action::RevealSecurity,
"hide-security" => Action::HideSecurity,
"reset-arm" => Action::ResetArm,
"reset-confirm" => Action::ResetConfirm,
"reset-cancel" => Action::ResetCancel,
"pricing-save" => Action::PricingSave,
"toggle-files" => Action::ToggleFiles,
"toggle-terminal" => Action::ToggleTerminal,
"show-tab" => Action::ShowTab(arg.unwrap_or_default()),
"feedback-submit" => Action::FeedbackSubmit,
"add-device" => Action::AddDevice,
"sync-devices" => Action::SyncDevices,
"adopt-device" => Action::AdoptDevice,
"create-new-claim" => Action::CreateNewClaim(arg.unwrap_or_default()),
"pair-cancel" => Action::PairCancel,
"save-prompt" => Action::SavePrompt,
"save-tool-allowlist" => Action::SaveToolAllowlist,
"reset-tool-allowlist" => Action::ResetToolAllowlist,
"save-api-key" => Action::SaveApiKey,
"toggle-display" => Action::ToggleDisplay,
"stop-turn" => Action::StopTurn,
"set-public-face" => Action::SetPublicFace(arg.unwrap_or_default()),
"set-model-access" => Action::SetModelAccess(arg.unwrap_or_default()),
"set-model" => Action::SetModel(arg.unwrap_or_default()),
"download-local-model" => Action::DownloadLocalModel,
"redeem-code" => Action::RedeemCode,
"redeem-banner" => Action::RedeemBanner,
"create-invite" => Action::CreateInvite,
"schedule-job" => Action::ScheduleJob,
"cancel-job" => Action::CancelJob(arg.unwrap_or_default()),
"post-bounty" => Action::PostBounty,
"claim-bounty" => Action::ClaimBounty(arg.unwrap_or_default()),
"create-guild" => Action::CreateGuild,
"fund-guild" => Action::FundGuild(arg.unwrap_or_default()),
"load-proposals" => Action::LoadProposals,
"propose-measure" => Action::ProposeMeasure,
"vote" => Action::Vote(arg.unwrap_or_default()),
"execute-proposal" => Action::ExecuteProposal(arg.unwrap_or_default()),
"save-x402-price" => Action::SaveX402Price,
"unlink-device" => Action::UnlinkDevice(arg.unwrap_or_default()),
"unlink-confirm" => Action::UnlinkConfirm(arg.unwrap_or_default()),
"unlink-cancel" => Action::UnlinkCancel,
_ => return None,
})
}
}
pub(crate) fn install_delegated_listeners(doc: &Document) -> Result<(), JsValue> {
let click = Closure::<dyn FnMut(_)>::new(move |event: MouseEvent| {
let Some(target) = event.target() else { return };
let Ok(mut node) = target.dyn_into::<Element>() else { return };
let action = loop {
if let Some(name) = node.get_attribute("data-action") {
let arg = node.get_attribute("data-arg");
break Action::parse(&name, arg);
}
match node.parent_element() {
Some(parent) => node = parent,
None => break None,
}
};
if let Some(action) = action {
event.prevent_default();
dispatch(action);
}
});
doc.add_event_listener_with_callback("click", click.as_ref().unchecked_ref())?;
click.forget();
let input_handler = Closure::<dyn FnMut(_)>::new(move |event: web_sys::Event| {
let Some(target) = event.target() else { return };
let Ok(el) = target.dyn_into::<Element>() else { return };
match el.id().as_str() {
"key" => on_key_input(),
"apex-input" => claim::on_apex_input(),
_ => {}
}
});
doc.add_event_listener_with_callback("input", input_handler.as_ref().unchecked_ref())?;
input_handler.forget();
let submit_handler = Closure::<dyn FnMut(_)>::new(move |event: web_sys::Event| {
let Some(target) = event.target() else { return };
let Ok(form) = target.dyn_into::<Element>() else { return };
if let Some(name) = form.get_attribute("data-action") {
if let Some(action) = Action::parse(&name, form.get_attribute("data-arg")) {
event.prevent_default();
dispatch(action);
}
}
});
doc.add_event_listener_with_callback("submit", submit_handler.as_ref().unchecked_ref())?;
submit_handler.forget();
let keydown = Closure::<dyn FnMut(_)>::new(move |event: KeyboardEvent| {
let key = event.key();
if key != "Enter" && key != " " {
return;
}
let Some(target) = event.target() else { return };
let Ok(el) = target.dyn_into::<Element>() else { return };
if el.get_attribute("role").as_deref() == Some("button") {
let mut node = el.clone();
let action = loop {
if let Some(name) = node.get_attribute("data-action") {
break Action::parse(&name, node.get_attribute("data-arg"));
}
match node.parent_element() {
Some(parent) => node = parent,
None => break None,
}
};
if let Some(action) = action {
event.prevent_default();
dispatch(action);
return;
}
}
if key != "Enter" || el.id() != "prompt" {
return;
}
let mod_held = event.meta_key() || event.ctrl_key();
let allow_newline = event.shift_key();
if mod_held || !allow_newline {
event.prevent_default();
dispatch(Action::Send);
}
});
doc.add_event_listener_with_callback("keydown", keydown.as_ref().unchecked_ref())?;
keydown.forget();
let mousemove = Closure::<dyn FnMut(_)>::new(move |event: MouseEvent| {
if dom::by_id("display-canvas").is_some() {
super::display::set_pointer(event.client_x() as f64, event.client_y() as f64);
}
});
doc.add_event_listener_with_callback("mousemove", mousemove.as_ref().unchecked_ref())?;
mousemove.forget();
let mousedown = Closure::<dyn FnMut(_)>::new(move |event: MouseEvent| {
if let Some(target) = event.target() {
if let Ok(el) = target.dyn_into::<Element>() {
if el.id() == "display-canvas" {
super::display::set_pointer(event.client_x() as f64, event.client_y() as f64);
super::display::set_pointer_down(true);
}
}
}
});
doc.add_event_listener_with_callback("mousedown", mousedown.as_ref().unchecked_ref())?;
mousedown.forget();
let mouseup = Closure::<dyn FnMut(_)>::new(move |_event: MouseEvent| {
super::display::set_pointer_down(false);
});
doc.add_event_listener_with_callback("mouseup", mouseup.as_ref().unchecked_ref())?;
mouseup.forget();
let touchstart = Closure::<dyn FnMut(_)>::new(move |event: web_sys::TouchEvent| {
if let Some(target) = event.target() {
if let Ok(el) = target.dyn_into::<Element>() {
if el.id() == "display-canvas" {
if let Some(t) = event.touches().get(0) {
super::display::set_pointer(t.client_x() as f64, t.client_y() as f64);
super::display::set_pointer_down(true);
}
}
}
}
});
doc.add_event_listener_with_callback("touchstart", touchstart.as_ref().unchecked_ref())?;
touchstart.forget();
let touchmove = Closure::<dyn FnMut(_)>::new(move |event: web_sys::TouchEvent| {
if dom::by_id("display-canvas").is_some() {
if let Some(t) = event.touches().get(0) {
super::display::set_pointer(t.client_x() as f64, t.client_y() as f64);
}
}
});
doc.add_event_listener_with_callback("touchmove", touchmove.as_ref().unchecked_ref())?;
touchmove.forget();
let touchend = Closure::<dyn FnMut(_)>::new(move |_event: web_sys::TouchEvent| {
super::display::set_pointer_down(false);
});
doc.add_event_listener_with_callback("touchend", touchend.as_ref().unchecked_ref())?;
touchend.forget();
install_keyboard_viewport_fix();
Ok(())
}
fn install_keyboard_viewport_fix() {
let Some(win) = web_sys::window() else { return };
let Some(vv) = win.visual_viewport() else { return };
let apply = move || {
let Some(win) = web_sys::window() else { return };
let Some(vv) = win.visual_viewport() else { return };
let Some(doc) = win.document() else { return };
let Some(root) = doc.document_element() else { return };
let Ok(html) = root.dyn_into::<HtmlElement>() else { return };
let style = html.style();
let layout_h = win
.inner_height()
.ok()
.and_then(|v| v.as_f64())
.unwrap_or(0.0);
let visible_h = vv.height();
let offset_top = vv.offset_top();
let occluded = layout_h - visible_h > 120.0;
if occluded {
let _ = style.set_property("--lh-vh", &format!("{visible_h}px"));
let _ = style.set_property("--lh-vv-top", &format!("{offset_top}px"));
let _ = html.class_list().add_1("lh-kb");
} else {
let _ = html.class_list().remove_1("lh-kb");
let _ = style.remove_property("--lh-vh");
let _ = style.remove_property("--lh-vv-top");
}
};
apply();
let cb = Closure::<dyn FnMut()>::new(apply);
let _ = vv.add_event_listener_with_callback("resize", cb.as_ref().unchecked_ref());
let _ = vv.add_event_listener_with_callback("scroll", cb.as_ref().unchecked_ref());
cb.forget(); }
fn on_key_input() {
if let Some(input) = dom::input_by_id("key") {
let value = input.value();
if let Ok(Some(storage)) = dom::session_storage() {
let _ = storage.set_item("gemini_api_key", &value);
}
refresh_keymeta();
wasm_bindgen_futures::spawn_local(async move {
super::key_store::save(&value).await;
});
}
}
pub(crate) fn refresh_keymeta() {
if let Some(input) = dom::input_by_id("key") {
let html = templates::keymeta(&input.value()).into_string();
dom::swap_inner("keymeta", &html);
}
}
fn dispatch(action: Action) {
match action {
Action::Send => {
wasm_bindgen_futures::spawn_local(async move {
super::chat::run_send().await;
});
}
Action::OpfsCloseViewer => super::opfs::close_viewer(),
Action::ToggleDisplay => super::opfs::toggle_display(),
Action::StopTurn => super::chat::request_stop_turn(),
Action::SetPublicFace(choice) => {
wasm_bindgen_futures::spawn_local(async move {
public_face::run_set_public_face(&choice).await;
});
}
Action::OpfsNav(target) => {
wasm_bindgen_futures::spawn_local(async move {
super::opfs::navigate(&target).await;
});
}
Action::OpfsOpen(name) => {
wasm_bindgen_futures::spawn_local(async move {
super::opfs::open_file(&name).await;
});
}
Action::OpfsEdit(name) => {
wasm_bindgen_futures::spawn_local(async move {
super::opfs::edit_file(&name).await;
});
}
Action::OpfsSave(name) => {
wasm_bindgen_futures::spawn_local(async move {
super::opfs::save_file(&name).await;
});
}
Action::ApexClaim => {
let raw = dom::input_by_id("apex-input")
.map(|i| i.value())
.unwrap_or_default();
let cleaned = super::tenant::sanitize(&raw);
if cleaned.len() < 3 || cleaned.len() > 32 {
return;
}
wasm_bindgen_futures::spawn_local(async move {
claim::run_apex_claim(cleaned, false).await;
});
}
Action::CreateNewClaim(name) => {
let cleaned = super::tenant::sanitize(&name);
if cleaned.len() < 3 || cleaned.len() > 32 {
return;
}
wasm_bindgen_futures::spawn_local(async move {
claim::run_apex_claim(cleaned, true).await;
});
}
Action::ClaimOnChain => {
let Some(name) = super::tenant::current_name() else {
return;
};
dom::swap_inner(
"claim-msg",
"<span style=\"color:var(--muted)\">ensuring identity at apex…</span>",
);
wasm_bindgen_futures::spawn_local(async move {
if let Err(err) = super::verify::create_wallet_via_iframe(false).await {
dom::swap_inner(
"claim-msg",
&dom::msg_span(dom::Msg::Error, &format!("identity setup failed: {err}")),
);
return;
}
dom::swap_inner(
"claim-msg",
"<span style=\"color:var(--muted)\">claiming on-chain…</span>",
);
match super::verify::claim_name_via_iframe(&name).await {
Ok((owner_addr, _tx)) => {
let _ = super::owner::remember(&owner_addr).await;
super::paint_tenant(
super::tenant::Host::Tenant(name.clone()),
name,
)
.await;
}
Err(err) => {
dom::swap_inner(
"claim-msg",
&dom::msg_span(dom::Msg::Error, &format!("claim failed: {err}")),
);
}
}
});
}
Action::RevealSeed => {
let phrase = super::APP.with(|cell| {
cell.borrow()
.wallet
.as_ref()
.map(|w| w.mnemonic.to_string())
});
if let Some(p) = phrase {
dom::swap_inner(
"seed-reveal",
&super::templates::seed_phrase(&p).into_string(),
);
} else if !matches!(super::tenant::current(), super::tenant::Host::Apex) {
dom::swap_inner(
"seed-reveal",
"<span style=\"color:var(--muted)\">fetching…</span>",
);
wasm_bindgen_futures::spawn_local(async move {
match super::verify::reveal_seed_via_iframe().await {
Ok(phrase) => dom::swap_inner(
"seed-reveal",
&super::templates::seed_phrase(&phrase).into_string(),
),
Err(err) => dom::swap_inner(
"seed-reveal",
&maud::html! {
span style="color:var(--error)" { "reveal failed: " (err) }
button type="button" data-action="reveal-seed" class="ghost" { "retry" }
}
.into_string(),
),
}
});
}
}
Action::HideSeed => {
dom::swap_inner(
"seed-reveal",
r#"<button type="button" data-action="reveal-seed">I have a pen and paper — reveal</button>"#,
);
}
Action::CreateIdentity => {
dom::swap_inner(
"identity-msg",
"<span style=\"color:var(--muted)\">generating identity…</span>",
);
match super::tenant::current() {
super::tenant::Host::Apex => {
wasm_bindgen_futures::spawn_local(async move {
if let Err(err) = super::wallet_store::create_and_persist().await {
dom::swap_inner(
"identity-msg",
&dom::msg_span(dom::Msg::Error, &format!("create failed: {err}")),
);
return;
}
super::paint_apex(super::tenant::Host::Apex).await;
});
}
host => {
wasm_bindgen_futures::spawn_local(async move {
match super::verify::create_wallet_via_iframe(true).await {
Ok(_addr) => {
if let super::tenant::Host::Tenant(name) = &host {
super::paint_tenant(host.clone(), name.clone()).await;
}
}
Err(err) => {
dom::swap_inner(
"identity-msg",
&dom::msg_span(dom::Msg::Error, &format!("create failed: {err}")),
);
}
}
});
}
}
}
Action::ShowImport => {
dom::swap_outer(
"import-slot",
&templates::import_seed_inline().into_string(),
);
if let Some(textarea) = dom::textarea_by_id("import-seed") {
let _ = textarea.focus();
}
}
Action::ImportSeed => {
let phrase = dom::textarea_by_id("import-seed")
.map(|t| t.value())
.unwrap_or_default();
if phrase.split_whitespace().count() != 12 {
dom::swap_inner(
"seed-msg",
"<span style=\"color:var(--error)\">expected exactly 12 words</span>",
);
return;
}
match super::tenant::current() {
super::tenant::Host::Apex => {
wasm_bindgen_futures::spawn_local(async move {
match super::wallet_store::import(&phrase).await {
Ok(_) => {
super::paint_apex(super::tenant::Host::Apex).await;
}
Err(err) => {
dom::swap_inner(
"seed-msg",
&dom::msg_span(dom::Msg::Error, &format!("import failed: {err}")),
);
}
}
});
}
host => {
wasm_bindgen_futures::spawn_local(async move {
match super::wallet_store::import(&phrase).await {
Ok(wallet) => {
super::APP
.with(|cell| cell.borrow_mut().wallet = Some(wallet));
if let super::tenant::Host::Tenant(name) = &host {
super::paint_tenant(host.clone(), name.clone()).await;
}
}
Err(err) => {
dom::swap_inner(
"seed-msg",
&dom::msg_span(dom::Msg::Error, &format!("import failed: {err}")),
);
}
}
});
}
}
}
Action::OpfsDelete(name) => {
wasm_bindgen_futures::spawn_local(async move {
let fs = super::shared_opfs();
if let Err(err) = fs.delete(&name).await {
web_sys::console::warn_1(&JsValue::from_str(&format!(
"delete({name}): {err}"
)));
}
if name == ".lh_history.json" {
dom::swap_inner("transcript", "");
}
super::opfs::refresh().await;
});
}
Action::OpfsWipe => {
dom::swap_outer(
"opfs-wipe-slot",
&templates::opfs_wipe_confirm_inline().into_string(),
);
}
Action::OpfsWipeConfirm => {
dom::swap_outer(
"opfs-wipe-slot",
&templates::opfs_wipe_armed_inline().into_string(),
);
wasm_bindgen_futures::spawn_local(async move {
super::opfs::wipe().await;
});
}
Action::OpfsWipeCancel => {
dom::swap_outer(
"opfs-wipe-slot",
&templates::opfs_wipe_armed_inline().into_string(),
);
}
Action::CancelImport => {
dom::swap_outer("import-slot", r#"<div id="import-slot"></div>"#);
}
Action::HeaderAdminToggle => admin::header_admin_toggle(),
Action::HeaderAdminClose => admin::header_admin_close(),
Action::ShowAdminTab(name) => admin::show_admin_tab(&name),
Action::RevealSecurity => {
dom::swap_outer(
"security-slot",
&templates::admin_security_expanded().into_string(),
);
}
Action::HideSecurity => {
dom::swap_outer(
"security-slot",
&templates::admin_security_collapsed().into_string(),
);
}
Action::ResetArm => {
dom::swap_outer(
"reset-confirm-slot",
&templates::reset_confirm_inline().into_string(),
);
}
Action::ResetCancel => {
dom::swap_outer(
"reset-confirm-slot",
&templates::reset_armed_inline().into_string(),
);
}
Action::ResetConfirm => layout::reset_confirm_pressed(),
Action::PricingSave => layout::pricing_save_pressed(),
Action::ToggleFiles => layout::toggle_layout_class("files-collapsed"),
Action::ToggleTerminal => layout::toggle_layout_class("terminal-collapsed"),
Action::ShowTab(name) => admin::show_mobile_tab(&name),
Action::FeedbackSubmit => super::feedback::feedback_submit(),
Action::AddDevice => devices::add_device_pressed(),
Action::SyncDevices => devices::run_sync_devices(),
Action::AdoptDevice => devices::adopt_device_pressed(),
Action::PairCancel => devices::pair_cancel_pressed(),
Action::SavePrompt => admin::save_prompt_pressed(),
Action::SaveToolAllowlist => admin::save_tool_allowlist_pressed(),
Action::ResetToolAllowlist => admin::reset_tool_allowlist_pressed(),
Action::SaveApiKey => admin::save_api_key_pressed(),
Action::SetModelAccess(mode) => credits::run_set_model_access(mode),
Action::SetModel(model) => credits::run_set_model(model),
Action::DownloadLocalModel => credits::run_download_local_model(),
Action::RedeemCode => credits::redeem_code_pressed(),
Action::RedeemBanner => credits::redeem_banner_pressed(),
Action::CreateInvite => credits::create_invite_pressed(),
Action::ScheduleJob => schedule::schedule_job_pressed(),
Action::CancelJob(id) => schedule::cancel_job_pressed(id),
Action::PostBounty => bounty::post_bounty_pressed(),
Action::ClaimBounty(id) => bounty::claim_bounty_pressed(id),
Action::CreateGuild => guild::create_guild_pressed(),
Action::FundGuild(id) => guild::fund_guild_pressed(id),
Action::LoadProposals => governance::load_proposals_pressed(),
Action::ProposeMeasure => governance::propose_measure_pressed(),
Action::Vote(arg) => governance::vote_pressed(arg),
Action::ExecuteProposal(id) => governance::execute_proposal_pressed(id),
Action::SaveX402Price => admin::save_x402_price_pressed(),
Action::UnlinkDevice(addr) => devices::unlink_device_prompt(addr),
Action::UnlinkConfirm(addr) => devices::unlink_confirm_pressed(addr),
Action::UnlinkCancel => devices::unlink_cancel_pressed(),
}
}
pub(crate) async fn refresh_credits_pill() {
let Some(addr) = super::chat::credit_address_existing().await else { return };
let wallet = super::net::read(super::registry::token_balance_of(&addr))
.await
.ok()
.and_then(Result::ok);
let meter = super::net::read(super::registry::credit_balance_of(&addr))
.await
.ok()
.and_then(Result::ok);
match (wallet, meter) {
(Some(wallet), Some(meter)) => {
let total = wallet + meter;
let whole = total / 1_000_000_000_000_000_000u128;
let cents = (total % 1_000_000_000_000_000_000u128) / 10_000_000_000_000_000u128;
dom::swap_inner("credits-balance", &format!("{whole}.{cents:02} LH"));
}
_ => dom::swap_inner("credits-balance", "—"),
}
warn_if_sponsor_low().await;
}
const SPONSOR_RL_WINDOW_SECS: u64 = 3600;
const SPONSOR_RL_MAX: usize = 60;
fn sponsor_rate_guard() -> Result<(), String> {
let Some(storage) = web_sys::window().and_then(|w| w.local_storage().ok().flatten()) else {
return Ok(()); };
let now = (js_sys::Date::now() / 1000.0) as u64;
let prev = storage
.get_item("lh_sponsor_rl")
.ok()
.flatten()
.unwrap_or_default();
let mut stamps: Vec<u64> = prev
.split(',')
.filter_map(|s| s.trim().parse::<u64>().ok())
.filter(|t| now.saturating_sub(*t) < SPONSOR_RL_WINDOW_SECS)
.collect();
if stamps.len() >= SPONSOR_RL_MAX {
return Err("too many sponsored actions in a short window — wait a bit".into());
}
stamps.push(now);
let joined = stamps
.iter()
.map(|t| t.to_string())
.collect::<Vec<_>>()
.join(",");
let _ = storage.set_item("lh_sponsor_rl", &joined);
Ok(())
}
pub(crate) async fn warn_if_sponsor_low() {
const ALPHA_USD_UNIT: u128 = 1_000_000;
const LOW_THRESHOLD: u128 = 5 * ALPHA_USD_UNIT; let Ok(signer) = super::sponsor::signer() else {
return;
};
let addr = crate::wallet::address(&signer);
let addr_hex = bytes_to_hex_str(&addr);
if let Ok(bal) =
super::registry::erc20_balance_of(super::registry::ALPHA_USD_ADDRESS, &addr_hex).await
{
if bal < LOW_THRESHOLD {
let whole = bal / ALPHA_USD_UNIT;
web_sys::console::warn_1(&JsValue::from_str(&format!(
"sponsor fee-token LOW: ~{whole} AlphaUSD at {addr_hex} — refill soon"
)));
}
}
}
pub(crate) async fn run_sponsored_tempo_call(
from_hex: &str,
calls: Vec<crate::tempo_tx::TempoCall>,
gas_limit: u128,
purpose: &str,
) -> Result<String, String> {
sponsor_rate_guard()?;
let sender_address = parse_address(from_hex)?;
let fee_token_addr = parse_address(super::registry::ALPHA_USD_ADDRESS)?;
let nonce = super::registry::next_nonce(from_hex).await
.map_err(|e| format!("nonce: {e}"))?;
let gas_price = super::registry::current_gas_price().await
.map_err(|e| format!("gas price: {e}"))?;
let tx = crate::tempo_tx::TempoTxBuilder::new(super::registry::CHAIN_ID)
.max_priority_fee_per_gas(gas_price)
.max_fee_per_gas(gas_price)
.gas_limit(gas_limit)
.nonce(nonce)
.calls(calls)
.fee_token(fee_token_addr)
.sponsored()
.build();
let sender_hash = tx.sender_hash();
let (claimed_addr, sender_sig) =
super::verify::sign_tempo_tx_via_iframe(&tx, purpose)
.await
.map_err(|e| format!("signer: {e}"))?;
let recovered = crate::wallet::recover_address(&sender_sig, &sender_hash)
.map_err(|e| format!("recover: {e}"))?;
if recovered != sender_address {
return Err(format!(
"sender sig recovered {} but expected {claimed_addr} ({from_hex})",
bytes_to_hex_str(&recovered),
));
}
let fee_payer = super::sponsor::signer()?;
let fp_hash = tx.fee_payer_hash(&sender_address);
let fp_sig = crate::wallet::sign_hash(&fee_payer, &fp_hash);
let raw = tx.serialize_signed(&sender_sig, Some(&fp_sig));
let raw_hex = bytes_to_hex_str(&raw);
super::registry::submit_and_wait_receipt(&raw_hex).await
.map_err(|e| format!("submit: {e}"))
}