use crate::app::{dom, APP};
use crate::encoding::{bytes_to_hex_str, parse_address};
pub(crate) async fn collect_payment_if_required() -> Result<Option<String>, String> {
use crate::app::VerifyState;
let (price_wei, verify_state, tba) = APP.with(|cell| {
let app = cell.borrow();
(
app.pricing_wei.unwrap_or(0),
app.verify_state.clone(),
app.tba_address.clone(),
)
});
if price_wei == 0 {
return Ok(None);
}
let Some(tba) = tba else {
return Err("agent is priced but its TBA isn't known yet (verification still running?)".into());
};
let visitor_address = match verify_state {
VerifyState::Verified { .. } => return Ok(None), VerifyState::Visitor { visitor_address, .. } => visitor_address,
VerifyState::Pending | VerifyState::Unregistered | VerifyState::Failed { .. } => {
return Err(
"agent is priced but owner verification didn't complete — refresh and retry"
.into(),
);
}
};
super::stage::enter(crate::turn_stage::Stage::Paying);
let purpose = format!(
"pay {} LH per turn to this agent",
crate::app::format_wei_as_test_eth(price_wei),
);
let tba_bytes = parse_address(&tba)?;
let mut tba_padded = [0u8; 32];
tba_padded[12..].copy_from_slice(&tba_bytes);
let amount_bytes = u256_be(price_wei);
let selector = transfer_selector();
let mut calldata = Vec::with_capacity(4 + 32 + 32);
calldata.extend_from_slice(&selector);
calldata.extend_from_slice(&tba_padded);
calldata.extend_from_slice(&amount_bytes);
let token_addr = parse_address(crate::registry::LOCALHARNESS_TOKEN_ADDRESS())?;
let call = crate::tempo_tx::TempoCall {
to: token_addr,
value_wei: 0,
input: calldata,
};
dom::set_status("payment: signing via apex…", false);
let tx_hash = crate::app::events::run_sponsored_tempo_call(
&visitor_address,
vec![call],
500_000,
&purpose,
)
.await
.map_err(|e| format!("payment: {e}"))?;
Ok(Some(tx_hash))
}
const CREDIT_PROXY_URL: &str = crate::registry::CREDIT_PROXY_URL;
pub(crate) fn model_access_is_credits() -> bool {
web_sys::window()
.and_then(|w| w.local_storage().ok().flatten())
.and_then(|s| s.get_item("lh_model_access").ok().flatten())
.map(|v| v != "byok")
.unwrap_or(true)
}
pub(crate) struct ModelAccess {
pub(crate) cfg_auth: String,
pub(crate) base_url: Option<url::Url>,
pub(crate) identity: String,
}
pub(crate) async fn credit_signer() -> Option<(k256::ecdsa::SigningKey, [u8; 20])> {
if let Some(pair) =
APP.with(|c| c.borrow().wallet.as_ref().map(|w| (w.signer.clone(), w.address)))
{
return Some(pair);
}
crate::app::debuglog::log("credit_signer: loading device key (opfs read)");
if let Some(sk) = crate::app::wallet_store::load_device_key().await {
crate::app::debuglog::log("credit_signer: device key loaded");
let addr = crate::wallet::address(&sk);
return Some((sk, addr));
}
crate::app::debuglog::log("credit_signer: no key — generating");
let w = crate::wallet::generate();
crate::app::debuglog::log("credit_signer: persisting device key (opfs write)");
crate::app::wallet_store::persist_device_key(&w.private_key_hex)
.await
.ok()?;
crate::app::debuglog::log("credit_signer: device key persisted");
Some((w.signer.clone(), w.address))
}
pub(crate) async fn credit_address_existing() -> Option<String> {
if let Some(a) = APP.with(|c| c.borrow().wallet.as_ref().map(|w| w.address_hex())) {
return Some(a);
}
let sk = crate::app::wallet_store::load_device_key().await?;
Some(bytes_to_hex_str(&crate::wallet::address(&sk)))
}
pub(crate) async fn resolve_credit_access() -> Option<ModelAccess> {
if model_access_is_credits() {
let (signer, addr) = credit_signer().await?;
let addr_hex = bytes_to_hex_str(&addr); let ts = (js_sys::Date::now() / 1000.0) as u64;
let msg = format!("localharness-proxy:{addr_hex}:{ts}");
let sig = crate::wallet::personal_sign(&signer, msg.as_bytes());
return Some(ModelAccess {
cfg_auth: format!("{addr_hex}:{ts}:{}", bytes_to_hex_str(&sig)),
base_url: url::Url::parse(CREDIT_PROXY_URL).ok(),
identity: format!("credits:{addr_hex}"),
});
}
let key = read_api_key().await?;
Some(ModelAccess {
cfg_auth: key.clone(),
base_url: None,
identity: key,
})
}
pub(crate) async fn ensure_credit_meter() {
let Some((signer, addr)) = credit_signer().await else {
return;
};
let addr_hex = bytes_to_hex_str(&addr);
let wallet = crate::app::registry::token_balance_of(&addr_hex)
.await
.unwrap_or(0);
if wallet == 0 {
return; }
let Ok(fee_payer) = crate::app::sponsor::signer() else {
return;
};
let _ = crate::app::registry::deposit_credits_sponsored(
&signer,
&fee_payer,
wallet,
crate::app::registry::ALPHA_USD_ADDRESS(),
)
.await;
}
async fn read_api_key() -> Option<String> {
if let Some(input) = dom::input_by_id("key") {
let v = input.value().trim().to_string();
if !v.is_empty() {
return Some(v);
}
}
if let Ok(Some(storage)) = dom::session_storage() {
if let Ok(Some(cached)) = storage.get_item("gemini_api_key") {
let trimmed = cached.trim();
if !trimmed.is_empty() {
return Some(trimmed.to_string());
}
}
}
if let Some(persisted) = crate::app::key_store::load().await {
let trimmed = persisted.trim().to_string();
if !trimmed.is_empty() {
return Some(trimmed);
}
}
None
}
pub(crate) async fn escrow_bridge_wei(from_hex: &str, needed_wei: u128) -> Result<u128, String> {
let wallet = crate::app::registry::token_balance_of(from_hex)
.await
.unwrap_or(0);
if wallet >= needed_wei {
return Ok(0);
}
let shortfall = needed_wei - wallet;
let meter = crate::app::registry::credit_balance_of(from_hex)
.await
.unwrap_or(0);
if meter < shortfall {
return Err(format!(
"needs {} $LH but the wallet holds {} and the chat meter {} — \
fund up with a redeem code, an invite, or a $LH transfer first",
crate::app::format_wei_as_test_eth(needed_wei),
crate::app::format_wei_as_test_eth(wallet),
crate::app::format_wei_as_test_eth(meter),
));
}
Ok(shortfall)
}
pub(crate) fn u256_be(value: u128) -> [u8; 32] {
let mut out = [0u8; 32];
out[16..].copy_from_slice(&value.to_be_bytes());
out
}
pub(crate) fn transfer_selector() -> [u8; 4] {
selector4(b"transfer(address,uint256)")
}
pub(crate) fn withdraw_credits_selector() -> [u8; 4] {
selector4(b"withdrawCredits(uint256)")
}
fn selector4(sig: &[u8]) -> [u8; 4] {
use sha3::{Digest, Keccak256};
let mut hasher = Keccak256::new();
hasher.update(sig);
let mut out = [0u8; 4];
out.copy_from_slice(&hasher.finalize()[..4]);
out
}
fn lh_transfer_calldata(to: &[u8; 20], amount_wei: u128) -> Vec<u8> {
let mut to_padded = [0u8; 32];
to_padded[12..].copy_from_slice(to);
let mut calldata = Vec::with_capacity(4 + 32 + 32);
calldata.extend_from_slice(&transfer_selector());
calldata.extend_from_slice(&to_padded);
calldata.extend_from_slice(&u256_be(amount_wei));
calldata
}
fn create_tba_calldata(token_id: u64) -> Vec<u8> {
let mut data = Vec::with_capacity(4 + 32);
data.extend_from_slice(&selector4(b"createTokenBoundAccount(uint256)"));
data.extend_from_slice(&u256_be(token_id as u128));
data
}
pub(crate) struct ActorSetup {
pub(crate) calls: Vec<crate::tempo_tx::TempoCall>,
pub(crate) extra_gas: u128,
pub(crate) prefunded_lh: Option<String>,
pub(crate) tba: Option<String>,
pub(crate) persona_set: bool,
}
pub(crate) async fn build_actor_setup(
creator: &str,
token_id: u64,
name: &str,
persona: Option<&str>,
prefund_lh: Option<&str>,
) -> Result<ActorSetup, crate::error::Error> {
let registry_addr =
parse_address(crate::app::registry::REGISTRY_ADDRESS()).map_err(crate::error::Error::other)?;
let mut calls: Vec<crate::tempo_tx::TempoCall> = Vec::new();
let mut extra_gas: u128 = 0;
let mut persona_set = false;
let mut prefunded_lh = None;
let mut tba_out = None;
if let Some(p) = persona {
let p = p.trim();
if !p.is_empty() {
calls.push(crate::tempo_tx::TempoCall {
to: registry_addr,
value_wei: 0,
input: crate::app::registry::encode_set_persona(token_id, p),
});
extra_gas += crate::app::gas::set_metadata_gas(p.len());
persona_set = true;
}
}
if let Some(amt_str) = prefund_lh {
let amt_str = amt_str.trim();
if !amt_str.is_empty() {
let amount_wei = crate::encoding::parse_token_amount(amt_str).ok_or_else(|| {
crate::error::Error::other(format!(
"could not parse prefund_lh \"{amt_str}\" — pass a decimal $LH figure \
like \"5\" or \"1.5\""
))
})?;
if amount_wei > 0 {
let bal = crate::app::registry::token_balance_of(creator)
.await
.map_err(crate::error::Error::other)?;
if bal < amount_wei {
return Err(crate::error::Error::other(format!(
"insufficient $LH to prefund: need {amt_str}, creator holds \
{} wei — redeem a code or lower prefund_lh",
bal
)));
}
let tba = crate::app::registry::tba_of_name(name)
.await
.map_err(crate::error::Error::other)?
.ok_or_else(|| {
crate::error::Error::other(
"could not resolve the new subdomain's token-bound account \
(TBA) to prefund — retry shortly",
)
})?;
let tba_bytes = parse_address(&tba).map_err(crate::error::Error::other)?;
let token_addr =
parse_address(crate::registry::LOCALHARNESS_TOKEN_ADDRESS())
.map_err(crate::error::Error::other)?;
calls.push(crate::tempo_tx::TempoCall {
to: registry_addr,
value_wei: 0,
input: create_tba_calldata(token_id),
});
calls.push(crate::tempo_tx::TempoCall {
to: token_addr,
value_wei: 0,
input: lh_transfer_calldata(&tba_bytes, amount_wei),
});
extra_gas += 1_500_000 + 500_000;
prefunded_lh = Some(amt_str.to_string());
tba_out = Some(tba);
}
}
}
let _ = creator; Ok(ActorSetup {
calls,
extra_gas,
prefunded_lh,
tba: tba_out,
persona_set,
})
}
pub(crate) fn short_hash(hash: &str) -> String {
let stripped = hash.trim_start_matches("0x");
if stripped.len() < 12 {
return hash.to_string();
}
format!("0x{}…{}", &stripped[..6], &stripped[stripped.len() - 4..])
}