use crate::app::chat::access::{
build_actor_setup, lh_transfer_calldata, u256_be, withdraw_credits_selector,
};
use crate::encoding::parse_address;
use crate::tools::ClosureTool;
async fn resolve_lh_recipient(tool: &str, recipient_arg: &str) -> Result<String, crate::error::Error> {
use crate::encoding::Recipient;
let kind = crate::encoding::classify_recipient(recipient_arg)
.map_err(|e| crate::error::Error::bad_args(tool, e))?;
match kind {
Recipient::Address(addr) => Ok(addr),
Recipient::Name(name) => crate::app::registry::owner_of_name(&name)
.await
.map_err(crate::error::Error::other)?
.ok_or_else(|| {
crate::error::Error::other(format!(
"no on-chain owner for subdomain \"{name}\" — is it registered?"
))
}),
}
}
async fn notifiable_recipient_name(recipient_arg: &str, to_hex: &str) -> Option<String> {
use crate::encoding::Recipient;
if let Ok(Recipient::Name(name)) = crate::encoding::classify_recipient(recipient_arg) {
return Some(name);
}
let main_id = crate::app::registry::main_of(to_hex).await.ok()?;
if main_id == 0 {
return None;
}
crate::app::registry::name_of_id(main_id).await.ok().filter(|n| !n.is_empty())
}
fn notify_recipient_of_incoming_lh(recipient_arg: String, to_hex: String, amount: String) {
wasm_bindgen_futures::spawn_local(async move {
let Some(name) = notifiable_recipient_name(&recipient_arg, &to_hex).await else {
return;
};
let title = format!("+{amount} $LH received");
let body = "incoming $LH transfer — check your wallet".to_string();
let _ = crate::app::chat::tools::misc::notify_cross_agent(&name, &title, &body).await;
});
}
fn lh_transfer_call(
to_hex: &str,
amount_wei: u128,
) -> Result<crate::tempo_tx::TempoCall, crate::error::Error> {
let to_bytes = parse_address(to_hex).map_err(crate::error::Error::other)?;
let calldata = lh_transfer_calldata(&to_bytes, amount_wei);
let token_addr = parse_address(crate::registry::LOCALHARNESS_TOKEN_ADDRESS())
.map_err(crate::error::Error::other)?;
Ok(crate::tempo_tx::TempoCall {
to: token_addr,
value_wei: 0,
input: calldata,
})
}
async fn meter_bridge_call(
from_hex: &str,
needed_wei: u128,
) -> Result<Option<crate::tempo_tx::TempoCall>, crate::error::Error> {
let shortfall = crate::app::chat::access::escrow_bridge_wei(from_hex, needed_wei)
.await
.map_err(crate::error::Error::other)?;
if shortfall == 0 {
return Ok(None);
}
let mut calldata = Vec::with_capacity(4 + 32);
calldata.extend_from_slice(&withdraw_credits_selector());
calldata.extend_from_slice(&u256_be(shortfall));
let diamond = parse_address(crate::registry::REGISTRY_ADDRESS())
.map_err(crate::error::Error::other)?;
Ok(Some(crate::tempo_tx::TempoCall {
to: diamond,
value_wei: 0,
input: calldata,
}))
}
pub(crate) fn create_subdomain_tool() -> std::sync::Arc<dyn crate::tools::Tool> {
let schema = crate::tool_params::CreateSubdomainParams::schema();
ClosureTool::new(
"create_subdomain",
"Register a new <name>.localharness.xyz subdomain on-chain (the ACTOR MODEL) — \
the owner's master wallet pays gas and ends up holding the resulting ERC-721 \
NFT. Give ONLY `name` for a bare name-only subdomain (\"create/make/spin up a \
subdomain\"); NEVER run_cartridge, which does not create a subdomain. Give \
`source` too (a rustlite cartridge, the SAME dialect as run_cartridge) to ALSO \
publish it as the subdomain's fullscreen public face in one call — the way to \
make a subdomain that IS an app (\"make me a clock/<app> subdomain\"). Compiles \
FIRST, so a bad cartridge fails before any write; publishes OFF-CHAIN (free, no \
gas). OWNERSHIP-AWARE with a source: an UNREGISTERED name is registered first, a \
name YOU already own is UPDATED in place (no re-register, no duplicate), a name \
owned by someone ELSE is refused. OPTIONAL actor extras: `persona` publishes the \
new agent's on-chain system instruction; `prefund_lh` moves that much $LH from \
your wallet into its token-bound account (its own spendable wallet). Returns \
{ name, url, ... }: name-only adds { owner, tx_hash }; a published app adds \
{ published: true, off_chain: true, updated }. Give the user the returned url as \
a clickable link.",
schema,
|args: serde_json::Value, _ctx| async move {
let params = crate::tool_params::CreateSubdomainParams::lenient(&args);
let name = params.name.trim();
let source = params.source.as_deref().map(str::trim).filter(|s| !s.is_empty());
let persona = params.persona.as_deref();
let cleaned = crate::subdomain::validate(name).map_err(|why| {
crate::error::Error::bad_args("create_subdomain", format!("invalid subdomain name: {why}"))
})?;
let prefund =
crate::app::chat::access::parse_prefund("create_subdomain", params.prefund_lh.as_deref())?;
match source {
Some(src) => create_subdomain_with_app(&cleaned, src, persona, prefund).await,
None => create_subdomain_name_only(&cleaned, persona, prefund).await,
}
},
)
}
async fn create_subdomain_name_only(
cleaned: &str,
persona: Option<&str>,
prefund: Option<(String, u128)>,
) -> Result<serde_json::Value, crate::error::Error> {
let (owner, claim_tx) = crate::app::verify::claim_name_via_iframe(cleaned)
.await
.map_err(|e| crate::error::Error::other(format!("claim failed: {e}")))?;
{
let n = cleaned.to_string();
wasm_bindgen_futures::spawn_local(async move {
crate::app::events::sync_local_key_to_main(&n).await;
});
}
let want_persona = persona.map(|p| !p.trim().is_empty()).unwrap_or(false);
let mut result = serde_json::json!({
"name": cleaned,
"url": format!("https://{cleaned}.localharness.xyz/"),
"owner": owner,
"tx_hash": claim_tx,
});
if want_persona || prefund.is_some() {
let token_id = match crate::app::registry::id_of_name(cleaned).await {
Ok(id) if id != 0 => id,
Ok(_) => {
return Err(crate::error::Error::other(
"registered but tokenId not yet visible on-chain — retry \
persona/prefund shortly",
))
}
Err(e) => return Err(crate::error::Error::other(format!("id_of_name: {e}"))),
};
let setup = build_actor_setup(
token_id,
cleaned,
persona,
prefund.as_ref().map(|(s, w)| (s.as_str(), *w)),
prefund_budget_wei(&owner, prefund.is_some()).await?,
)
.await?;
if !setup.calls.is_empty() {
let tx_hash = crate::app::events::run_sponsored_tempo_call(
&owner,
setup.calls,
setup.extra_gas,
"spawn actor (persona + prefund)",
)
.await
.map_err(|e| crate::error::Error::other(format!("actor setup failed: {e}")))?;
result["setup_tx_hash"] = serde_json::json!(tx_hash);
result["persona_set"] = serde_json::json!(setup.persona_set);
if let Some(amt) = setup.prefunded_lh {
result["prefunded_lh"] = serde_json::json!(amt);
}
if let Some(tba) = setup.tba {
result["tba"] = serde_json::json!(tba);
}
}
}
Ok(result)
}
async fn create_subdomain_with_app(
cleaned: &str,
source: &str,
persona: Option<&str>,
prefund: Option<(String, u128)>,
) -> Result<serde_json::Value, crate::error::Error> {
let signer_owner = crate::app::tenant::current_tenant_owner()
.await
.map(|(_, o)| o)
.ok();
let existing = match &signer_owner {
Some(o) => owned_token_for_publish(cleaned, o).await?,
None => match crate::app::registry::owner_of_name(cleaned).await {
Ok(Some(_)) => {
return Err(crate::error::Error::other(format!(
"\"{cleaned}\" is already registered — run this on your own \
subdomain so ownership can be verified before updating it"
)))
}
Ok(None) => None,
Err(e) => return Err(crate::error::Error::other(format!("owner_of_name: {e}"))),
},
};
if let Some((_token_id, owner)) = existing {
let wasm = publish_app_face(cleaned, source, &owner).await?;
stash_published_app_embed(cleaned, wasm);
return Ok(serde_json::json!({
"name": cleaned,
"url": format!("https://{cleaned}.localharness.xyz/"),
"published": true,
"off_chain": true,
"updated": true,
}));
}
let (owner, _claim_tx) = crate::app::verify::claim_name_via_iframe(cleaned)
.await
.map_err(|e| crate::error::Error::other(format!("claim failed: {e}")))?;
{
let n = cleaned.to_string();
wasm_bindgen_futures::spawn_local(async move {
crate::app::events::sync_local_key_to_main(&n).await;
});
}
let token_id = match crate::app::registry::id_of_name(cleaned).await {
Ok(id) if id != 0 => id,
Ok(_) => {
return Err(crate::error::Error::other(
"registered but tokenId not yet visible on-chain — retry publish shortly",
))
}
Err(e) => return Err(crate::error::Error::other(format!("id_of_name: {e}"))),
};
let wasm = publish_app_face(cleaned, source, &owner).await?;
stash_published_app_embed(cleaned, wasm);
let setup = build_actor_setup(
token_id,
cleaned,
persona,
prefund.as_ref().map(|(s, w)| (s.as_str(), *w)),
prefund_budget_wei(&owner, prefund.is_some()).await?,
)
.await?;
let setup_tx = if setup.calls.is_empty() {
None
} else {
Some(
crate::app::events::run_sponsored_tempo_call(
&owner,
setup.calls,
setup.extra_gas,
"actor setup (persona/prefund)",
)
.await
.map_err(|e| crate::error::Error::other(format!("actor setup failed: {e}")))?,
)
};
let mut result = serde_json::json!({
"name": cleaned,
"url": format!("https://{cleaned}.localharness.xyz/"),
"published": true,
"off_chain": true,
"updated": false,
});
if let Some(tx) = setup_tx {
result["tx_hash"] = serde_json::json!(tx);
}
if setup.persona_set {
result["persona_set"] = serde_json::json!(true);
}
if let Some(amt) = setup.prefunded_lh {
result["prefunded_lh"] = serde_json::json!(amt);
}
if let Some(tba) = setup.tba {
result["tba"] = serde_json::json!(tba);
}
Ok(result)
}
async fn prefund_budget_wei(owner: &str, want: bool) -> Result<u128, crate::error::Error> {
if !want {
return Ok(0);
}
crate::app::registry::token_balance_of(owner)
.await
.map_err(crate::error::Error::other)
}
fn owner_master_signer(
tool: &str,
owner: &str,
) -> Result<k256::ecdsa::SigningKey, crate::error::Error> {
let master = crate::app::APP
.with(|c| c.borrow().wallet.as_ref().map(|w| (w.signer.clone(), w.address)));
match master {
Some((signer, addr))
if owner.eq_ignore_ascii_case(&crate::encoding::bytes_to_hex_str(&addr)) =>
{
Ok(signer)
}
_ => Err(crate::error::Error::other(format!(
"{tool}: publishing needs this device to hold the owner wallet of the name \
(owner {owner}); TBA-owned names / linked devices without the seed can't \
publish yet"
))),
}
}
async fn publish_app_face(
name: &str,
source: &str,
owner: &str,
) -> Result<Vec<u8>, crate::error::Error> {
if source.trim().is_empty() {
return Err(crate::error::Error::other("source cannot be empty"));
}
let wasm = crate::batch_apps::compile_source(
source,
crate::app::registry::APP_STORE_MAX_WASM_BYTES,
)
.map_err(crate::error::Error::other)?;
publish_wasm_face(name, source, &wasm, owner).await?;
Ok(wasm)
}
async fn publish_wasm_face(
name: &str,
source: &str,
wasm: &[u8],
owner: &str,
) -> Result<(), crate::error::Error> {
let signer = owner_master_signer("publish", owner)?;
let now = (js_sys::Date::now() / 1000.0) as u64;
let token = crate::registry::proxy_auth_token(&signer, now, "publish");
crate::app::registry::publish_app_to_store(name, &token, wasm, source)
.await
.map_err(|e| crate::error::Error::other(format!("publish failed: {e}")))
}
async fn publish_html_face(
name: &str,
html: &[u8],
owner: &str,
) -> Result<(), crate::error::Error> {
if html.is_empty() {
return Err(crate::error::Error::other("index.html is empty"));
}
if html.len() > crate::app::registry::APP_STORE_MAX_WASM_BYTES {
return Err(crate::error::Error::other(format!(
"index.html too large to publish: {} bytes (max {})",
html.len(),
crate::app::registry::APP_STORE_MAX_WASM_BYTES
)));
}
let signer = owner_master_signer("publish", owner)?;
let now = (js_sys::Date::now() / 1000.0) as u64;
let token = crate::registry::proxy_auth_token(&signer, now, "publish");
let html_str = String::from_utf8_lossy(html).into_owned();
crate::app::registry::publish_html_to_store(name, &token, &html_str)
.await
.map_err(|e| crate::error::Error::other(format!("publish failed: {e}")))
}
async fn owned_token_for_publish(
name: &str,
signer_owner: &str,
) -> Result<Option<(u64, String)>, crate::error::Error> {
let owner = match crate::app::registry::owner_of_name(name).await {
Ok(Some(o)) => o,
Ok(None) => return Ok(None),
Err(e) => return Err(crate::error::Error::other(format!("owner_of_name: {e}"))),
};
if !owner.eq_ignore_ascii_case(signer_owner) {
return Err(crate::error::Error::other(format!(
"\"{name}\" is owned by {owner}, not you ({signer_owner}) — you can only \
publish to subdomains you own"
)));
}
let token_id = match crate::app::registry::id_of_name(name).await {
Ok(id) if id != 0 => id,
Ok(_) => {
return Err(crate::error::Error::other(format!(
"\"{name}\" has an owner but no tokenId yet — retry shortly"
)))
}
Err(e) => return Err(crate::error::Error::other(format!("id_of_name: {e}"))),
};
Ok(Some((token_id, owner)))
}
fn stash_published_app_embed(name: &str, wasm: Vec<u8>) {
crate::app::display::set_cartridge_ref(Some(format!("published app: {name}")));
crate::app::display::run_wasm_inline(&wasm);
}
pub(crate) fn publish_app_to_tool() -> std::sync::Arc<dyn crate::tools::Tool> {
let schema = crate::tool_params::PublishAppToParams::schema();
ClosureTool::new(
"publish_app_to",
"Publish (UPDATE) a rustlite cartridge to ANOTHER subdomain you OWN — the \
update-from-MAIN path. The owner's master wallet holds all their subdomain \
NFTs, so from one session you can re-publish any of your alts' apps. The \
target must ALREADY exist and be owned by you (to mint a NEW subdomain use \
create_subdomain with a `source`; that also updates the CURRENT name in place). \
OVERWRITES that subdomain's published app (off-chain, free) — the first \
call does NOT execute: it returns a single-use confirmation code (also \
shown to the owner in the UI). Say which subdomain you'll update, ask the \
owner to TYPE the code, then retry with `confirmation` set to it. \
Returns { name, url, off_chain, updated: true }.",
schema,
|args: serde_json::Value, _ctx| async move {
let params = crate::tool_params::PublishAppToParams::lenient(&args);
let name = params.name.trim();
let source = params.source.as_str();
let confirmed = params
.confirmation
.as_deref()
.map(|s| !s.trim().is_empty())
.unwrap_or(false);
if !confirmed {
return Err(crate::error::Error::bad_args(
"publish_app_to",
"publish_app_to requires the platform-issued confirmation code",
));
}
let cleaned = crate::subdomain::validate(name).map_err(|why| {
crate::error::Error::bad_args("publish_app_to", format!("invalid subdomain name: {why}"))
})?;
if source.trim().is_empty() {
return Err(crate::error::Error::bad_args("publish_app_to", "source cannot be empty"));
}
let (_, signer_owner) = crate::app::tenant::current_tenant_owner()
.await
.map_err(crate::error::Error::other)?;
let (_token_id, owner) = owned_token_for_publish(&cleaned, &signer_owner)
.await?
.ok_or_else(|| {
crate::error::Error::other(format!(
"\"{cleaned}\" is not registered — use create_subdomain with a \
`source` to mint and publish a new subdomain"
))
})?;
let _wasm = publish_app_face(&cleaned, source, &owner).await?;
Ok(serde_json::json!({
"name": cleaned,
"url": format!("https://{cleaned}.localharness.xyz/"),
"off_chain": true,
"updated": true,
}))
},
)
}
pub(crate) fn embed_app_tool() -> std::sync::Arc<dyn crate::tools::Tool> {
let schema = crate::tool_params::EmbedAppParams::schema();
ClosureTool::new(
"embed_app",
"Embed another subdomain's published cartridge INLINE in this chat as a \
live, interactive card (the cartridge runs in the framebuffer, like the \
display — NOT an iframe). Use this to show/play <name>'s app right here \
(\"embed pong\", \"show me <name>'s app\"). Single live embed at a time: \
embedding replaces any cartridge already running. Only works when <name> \
has PUBLISHED a cartridge (an app public face) — directory/html faces or \
unpublished names return an error. Returns { name, url, embedded: true }.",
schema,
|args: serde_json::Value, _ctx| async move {
let params = crate::tool_params::EmbedAppParams::lenient(&args);
let name = params.name.trim();
let cleaned = crate::app::tenant::sanitize(name);
if cleaned.is_empty() {
return Err(crate::error::Error::bad_args("embed_app", "name cannot be empty"));
}
let token_id = match crate::app::registry::id_of_name(&cleaned).await {
Ok(id) if id != 0 => id,
Ok(_) => {
return Err(crate::error::Error::other(format!(
"\"{cleaned}\" is not registered"
)))
}
Err(e) => return Err(crate::error::Error::other(format!("id_of_name: {e}"))),
};
let wasm = match crate::app::registry::app_wasm_of(token_id).await {
Ok(Some(bytes)) if !bytes.is_empty() => bytes,
Ok(_) => {
return Err(crate::error::Error::other(format!(
"{cleaned} has no published cartridge — only directory/html \
faces or unpublished"
)))
}
Err(e) => return Err(crate::error::Error::other(format!("app_wasm_of: {e}"))),
};
crate::app::display::set_cartridge_ref(Some(format!("embedded app: {cleaned}")));
crate::app::display::stash_pending_embed(wasm);
Ok(serde_json::json!({
"name": cleaned,
"url": format!("https://{cleaned}.localharness.xyz/"),
"embedded": true,
}))
},
)
}
pub(crate) fn publish_public_face_tool() -> std::sync::Arc<dyn crate::tools::Tool> {
let schema = crate::tool_params::PublishPublicFaceParams::schema();
ClosureTool::new(
"publish_public_face",
"Publish YOUR OWN public face — what a visitor to \
https://<you>.localharness.xyz/ sees — the chat equivalent of admin → \
public face. `choice`: \"app\" compiles + publishes this device's local \
app.rl as a fullscreen cartridge; \"html\" publishes local index.html; \
\"directory\" sets a profile landing. All OFF-CHAIN to the app store \
(free, no gas, no transaction). Zero-click. Works only on your own \
subdomain. After it succeeds, give the user the returned `url`. \
Returns { choice, url, off_chain }.",
schema,
|args: serde_json::Value, _ctx| async move {
let choice = crate::tool_params::PublishPublicFaceParams::lenient(&args)
.choice
.trim()
.to_lowercase();
if !matches!(choice.as_str(), "directory" | "app" | "html") {
return Err(crate::error::Error::bad_args(
"publish_public_face",
"choice must be \"directory\", \"app\", or \"html\"",
));
}
let Some(name) = crate::app::tenant::current_name() else {
return Err(crate::error::Error::other(
"publish_public_face only works on your own subdomain",
));
};
let owner = match crate::app::registry::owner_of_name(&name).await {
Ok(Some(o)) => o,
_ => return Err(crate::error::Error::other("name isn't registered on-chain")),
};
match choice.as_str() {
"directory" => {
let signer = owner_master_signer("publish_public_face", &owner)?;
let now = (js_sys::Date::now() / 1000.0) as u64;
let token = crate::registry::proxy_auth_token(&signer, now, "publish");
crate::app::registry::publish_face_to_store(&name, &token, "directory")
.await
.map_err(|e| {
crate::error::Error::other(format!("publish failed: {e}"))
})?;
}
"app" => {
let fs = crate::app::shared_opfs();
let src = match fs.read("app.rl").await {
Ok(b) if !b.is_empty() => String::from_utf8_lossy(&b).into_owned(),
_ => {
return Err(crate::error::Error::other(
"no app.rl on this device — build one first (run_cartridge), \
then publish",
))
}
};
let _wasm = publish_app_face(&name, &src, &owner).await?;
}
"html" => {
let fs = crate::app::shared_opfs();
let html = match fs.read("index.html").await {
Ok(b) if !b.is_empty() => b,
_ => {
return Err(crate::error::Error::other(
"no index.html on this device — create one first, then publish",
))
}
};
publish_html_face(&name, &html, &owner).await?;
}
_ => unreachable!(),
}
Ok(serde_json::json!({
"choice": choice,
"url": format!("https://{name}.localharness.xyz/"),
"off_chain": true,
}))
},
)
}
pub(crate) fn release_subdomain_tool() -> std::sync::Arc<dyn crate::tools::Tool> {
let schema = crate::tool_params::ReleaseSubdomainParams::schema();
ClosureTool::new(
"release_subdomain",
"DESTRUCTIVE + IRREVERSIBLE: burn a subdomain NFT and free its name. The first \
call does NOT execute: it returns a single-use confirmation code (also shown to \
the owner in the UI). Ask the owner to TYPE that code in chat, then retry with \
`confirmation` set to it — the call only executes after the owner's message \
contains the code. Refuses your MAIN. Returns the tx hash.",
schema,
|args: serde_json::Value, _ctx| async move {
let params = crate::tool_params::ReleaseSubdomainParams::lenient(&args);
let name = params.name.trim().to_string();
if name.is_empty() {
return Err(crate::error::Error::bad_args("release_subdomain", "name is required"));
}
let confirmed = params
.confirmation
.as_deref()
.map(|s| !s.trim().is_empty())
.unwrap_or(false);
if !confirmed {
return Err(crate::error::Error::bad_args(
"release_subdomain",
"release_subdomain requires the platform-issued confirmation code",
));
}
match crate::app::events::run_release_subdomain(&name).await {
Ok(tx) => Ok(serde_json::json!({ "released": name, "tx_hash": tx })),
Err(e) => Err(crate::error::Error::other(format!("release failed: {e}"))),
}
},
)
}
pub(crate) fn bulk_release_subdomains_tool() -> std::sync::Arc<dyn crate::tools::Tool> {
let schema = serde_json::json!({
"type": "object",
"properties": {
"names": {
"type": "array",
"items": { "type": "string" },
"description": "OPTIONAL subset of subdomain names to release in one \
batch. Omit to target EVERY non-MAIN subdomain the owner holds \
(errors when the owner holds more than 28 — pass subsets instead). \
At most 28 names per call — for more, pass explicit subsets in \
separate calls."
},
"confirmation": {
"type": "string",
"description": "Single-use confirmation code. OMIT (or pass \"\") on the \
first call — it returns a challenge code shown to the owner. Show the \
owner the exact list that will be burned (list_subdomains is the \
read-only source), ask them to TYPE the code, then retry with it. \
Never invent it; only the platform issues it."
}
},
"required": []
});
ClosureTool::new(
"bulk_release_subdomains",
"DESTRUCTIVE + IRREVERSIBLE: burn MANY subdomain NFTs and free their names in \
ONE batch. With no `names`, releases EVERY non-MAIN subdomain the owner holds \
(errors when that exceeds 28 — pass subsets instead); with `names`, only that \
subset. At most 28 names per call — for more, pass \
explicit `names` subsets in separate calls. The first call does NOT execute: it \
returns a single-use confirmation code (also shown to the owner in the UI). Show \
the owner the exact list that will be burned (use list_subdomains), ask them to \
TYPE the code, then retry with `confirmation` set to it. ONE code for the whole \
batch; more than 8 names are split across multiple sponsored txs automatically \
(each tx burns at most 8). Returns { released, skipped, count, tx_hashes, \
failed, unconfirmed, unattempted } — `skipped` lists names with nothing left to \
burn (already gone — handled without a tx, not an error); `failed` lists chunks \
whose tx FAILED (those names were NOT burned); `unconfirmed` lists chunks whose \
receipt TIMED OUT (the tx MAY still land — check its tx_hash before retrying; \
the batch stops there); `unattempted` lists names never tried (the batch stops \
early after 2 consecutive failed chunks, an unconfirmed chunk, or a user Stop).",
schema,
|args: serde_json::Value, _ctx| async move {
let confirmed = args
.get("confirmation")
.and_then(|v| v.as_str())
.map(|s| !s.trim().is_empty())
.unwrap_or(false);
if !confirmed {
return Err(crate::error::Error::bad_args(
"bulk_release_subdomains",
"bulk_release_subdomains requires the platform-issued confirmation code",
));
}
let (_, owner) = crate::app::tenant::current_tenant_owner()
.await
.map_err(crate::error::Error::other)?;
let main_id = crate::app::registry::main_of(&owner)
.await
.map_err(crate::error::Error::other)?;
let explicit: Vec<String> = args
.get("names")
.and_then(|v| v.as_array())
.map(|a| {
a.iter()
.filter_map(|v| v.as_str())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
})
.unwrap_or_default();
let targets: Vec<String> = if explicit.is_empty() {
let tokens = crate::app::registry::list_owned_tokens(&owner)
.await
.map_err(crate::error::Error::other)?;
tokens
.into_iter()
.filter(|t| main_id == 0 || t.token_id != main_id)
.map(|t| t.name)
.collect()
} else {
explicit
};
if targets.is_empty() {
return Ok(serde_json::json!({
"status": "nothing_to_release",
"note": "no non-MAIN subdomains to release"
}));
}
if let Some(msg) =
crate::relay_chunk::over_batch_limit("bulk_release_subdomains", targets.len())
{
return Err(crate::error::Error::bad_args(
"bulk_release_subdomains",
format!("{msg} Pass explicit `names` subsets."),
));
}
let ranges = crate::relay_chunk::chunk_ranges(targets.len(), false);
let mut outcomes: Vec<crate::relay_chunk::ChunkOutcome> =
Vec::with_capacity(ranges.len());
let mut released_all: Vec<String> = Vec::new();
for r in &ranges {
if crate::app::chat::turn_cancelled()
|| crate::relay_chunk::should_stop(&outcomes)
{
break;
}
match crate::app::events::run_bulk_release(&targets[r.clone()]).await {
Ok((released, tx)) => {
released_all.extend(released);
outcomes.push(crate::relay_chunk::ChunkOutcome::Landed(tx));
}
Err(e) if e == crate::app::events::NO_RELEASABLE_NAMES => {
outcomes.push(crate::relay_chunk::ChunkOutcome::Landed(String::new()));
}
Err(e) => outcomes.push(crate::relay_chunk::classify_failure(e)),
}
}
let fold = crate::relay_chunk::fold_outcomes(&ranges, &outcomes);
if released_all.is_empty()
&& fold.unconfirmed.is_empty()
&& fold.unattempted.is_empty()
{
let msg = fold
.chunk_errors
.first()
.map(|(_, e)| e.clone())
.unwrap_or_else(|| crate::app::events::NO_RELEASABLE_NAMES.to_string());
return Err(crate::error::Error::other(format!("bulk release failed: {msg}")));
}
let released_set: std::collections::HashSet<&str> =
released_all.iter().map(|s| s.as_str()).collect();
let skipped: Vec<&String> = fold
.landed
.iter()
.map(|&i| &targets[i])
.filter(|n| !released_set.contains(n.as_str()))
.collect();
let failed: Vec<serde_json::Value> = fold
.chunk_errors
.iter()
.map(|(ci, err)| serde_json::json!({
"names": targets[ranges[*ci].clone()],
"error": err,
}))
.collect();
let unconfirmed: Vec<serde_json::Value> = fold
.unconfirmed_txs
.iter()
.map(|(ci, tx)| serde_json::json!({
"names": targets[ranges[*ci].clone()],
"tx_hash": tx,
"note": "receipt timed out — the tx may still land; check the \
tx hash before retrying these names",
}))
.collect();
let unattempted: Vec<&String> =
fold.unattempted.iter().map(|&i| &targets[i]).collect();
Ok(serde_json::json!({
"released": released_all,
"skipped": skipped,
"count": released_all.len(),
"tx_hashes": fold.tx_hashes,
"failed": failed,
"unconfirmed": unconfirmed,
"unattempted": unattempted,
}))
},
)
}
fn batch_name_fields(
cleaned: &str,
requested: &str,
) -> serde_json::Map<String, serde_json::Value> {
let mut m = serde_json::Map::new();
let key = if cleaned.is_empty() { requested } else { cleaned };
m.insert("name".into(), serde_json::Value::String(key.into()));
if key != requested {
m.insert("requested".into(), serde_json::Value::String(requested.into()));
}
m
}
pub(crate) fn batch_create_subdomains_tool() -> std::sync::Arc<dyn crate::tools::Tool> {
let schema = crate::batch_apps::input_schema();
ClosureTool::new(
"batch_create_subdomains",
"Register MANY <name>.localharness.xyz subdomains on-chain in batched \
sponsored transactions — and OPTIONALLY publish an app onto each in \
the SAME call. PREFER THIS over calling create_subdomain in a loop \
when creating more than one name. Pass EITHER `names` (name-only \
registrations) OR `items` ([{name, source?}] — an item with a \
rustlite `source` ALSO publishes it as that subdomain's fullscreen \
public face, the create_subdomain `source` behavior batched). Every \
source compiles FIRST: a compile failure fails THAT item up front \
(listed in `compile_failed` with the compiler diagnostic) and it is \
never registered, so a bad cartridge spends nothing. A source item \
whose name you ALREADY OWN is updated IN PLACE (published, no \
re-register, no fee); a name owned by someone ELSE is skipped and \
NOT published. Publishing is OFF-CHAIN and free; a publish failure \
(listed in `publish_failed`) never means the registration failed. \
The owner's master wallet ends up holding every new ERC-721 NFT. \
SPENDS $LH (each registration costs the on-chain registration fee — \
1 $LH on mainnet), so the first call does NOT execute: it returns a \
single-use confirmation code (also shown to the owner in the UI). \
List the names, ask the owner to TYPE the code, then retry with \
`confirmation` set to it. At most 28 items per call — split a bigger \
request into separate calls; the SAME name twice in one call is a \
hard error. Taken or invalid names are skipped (not an error) and \
listed in `skipped`, with the per-name WHY in `skipped_reasons`. \
More than 7 registrations are \
split across multiple sponsored txs automatically (each tx carries \
at most 8 calls). `failed` lists chunks whose tx FAILED (those names \
were NOT registered); `unconfirmed` lists chunks whose receipt TIMED \
OUT (the tx MAY still land — check its tx_hash before retrying; the \
batch stops there); `unattempted` lists names never tried (the batch \
stops early after 2 consecutive failed chunks, an unconfirmed chunk, \
or a user Stop). A source item whose name did NOT end up registered \
this call (skipped/failed/unconfirmed/unattempted) is NOT published \
— it is listed in `publish_skipped` with its registration_state and \
what to do next (an unconfirmed name may still land; if it does, \
publish with publish_app_to or re-run the item). A user Stop pressed \
after a name landed but before its publish ALSO rides \
`publish_skipped` (registration_state 'registered' or \
'update_in_place') — re-run or use publish_app_to. `urls` lists ONLY \
links that work after this call: name-only registrations plus \
successfully published apps — a registered name whose app publish \
failed is absent from `urls`. Returns { registered, skipped, \
skipped_reasons: [{name, reason}], count, tx_hashes, \
failed, unconfirmed, unattempted, urls, published: [{name, url, \
updated}], publish_failed: [{name, error, registered}], \
publish_skipped: [{name, registration_state, reason}], \
compile_failed: [{name, error}] } — an in-place update appears in \
`published` with updated: true and NOT in `registered` (no tx was \
needed). Result buckets key on the SANITISED name; an entry adds \
`requested` (the spelling you passed) only when it differed. The \
legacy `skipped` string list stays as-requested.",
schema,
|args: serde_json::Value, _ctx| async move {
let items = crate::batch_apps::parse_items(&args)
.map_err(|e| crate::error::Error::bad_args("batch_create_subdomains", e))?;
if let Some(msg) =
crate::relay_chunk::over_batch_limit("batch_create_subdomains", items.len())
{
return Err(crate::error::Error::bad_args("batch_create_subdomains", msg));
}
let confirmed = args
.get("confirmation")
.and_then(|v| v.as_str())
.map(|s| !s.trim().is_empty())
.unwrap_or(false);
if !confirmed {
return Err(crate::error::Error::bad_args(
"batch_create_subdomains",
"batch_create_subdomains requires the platform-issued confirmation code",
));
}
use crate::batch_apps::{ItemPlan, ItemState};
let any_source = items.iter().any(|it| it.source.is_some());
let mut compiled: Vec<Option<Result<Vec<u8>, String>>> =
Vec::with_capacity(items.len());
for it in &items {
let one = it.source.as_deref().map(|s| {
crate::batch_apps::compile_source(
s,
crate::app::registry::APP_STORE_MAX_WASM_BYTES,
)
});
let did_compile = one.is_some();
compiled.push(one);
if did_compile {
crate::runtime::sleep_ms(0).await;
}
}
let signer_owner: Option<String> = if any_source {
Some(
crate::app::tenant::current_tenant_owner()
.await
.map_err(|e| {
crate::error::Error::other(format!(
"publishing in a batch needs to run on your own \
subdomain so ownership can be verified: {e}"
))
})?
.1,
)
} else {
None
};
let cleaned: Vec<String> =
items.iter().map(|it| crate::app::tenant::sanitize(&it.name)).collect();
let mut plan: Vec<ItemPlan> = items
.iter()
.enumerate()
.map(|(i, it)| match &compiled[i] {
Some(Err(e)) => ItemPlan::CompileFailed(e.clone()),
Some(Ok(_)) => match crate::subdomain::validate(&it.name) {
Err(why) => {
ItemPlan::PreSkipped(format!("invalid subdomain name: {why}"))
}
Ok(_) => ItemPlan::Register,
},
None => ItemPlan::Register,
})
.collect();
let lookups: Vec<usize> = (0..items.len())
.filter(|&i| {
matches!(compiled[i], Some(Ok(_)))
&& matches!(plan[i], ItemPlan::Register)
})
.collect();
for burst in lookups.chunks(8) {
let owners = futures_util::future::join_all(
burst.iter().map(|&i| crate::app::registry::owner_of_name(&cleaned[i])),
)
.await;
for (&i, owner) in burst.iter().zip(owners) {
let owner = match owner {
ok @ Ok(_) => ok,
Err(_) => crate::app::registry::owner_of_name(&cleaned[i]).await,
};
plan[i] = match owner {
Ok(None) => ItemPlan::Register,
Ok(Some(o))
if signer_owner
.as_deref()
.map(|s| o.eq_ignore_ascii_case(s))
.unwrap_or(false) =>
{
ItemPlan::UpdateInPlace
}
Ok(Some(o)) => ItemPlan::PreSkipped(format!(
"owned by {o}, not you — not registered, app NOT published"
)),
Err(e) => {
return Err(crate::error::Error::other(format!(
"owner_of_name: {e}"
)))
}
};
}
}
let reg_indices = crate::batch_apps::register_set(&plan);
let reg_requested: Vec<String> =
reg_indices.iter().map(|&i| items[i].name.clone()).collect();
let ranges = crate::relay_chunk::chunk_ranges(reg_requested.len(), true);
let mut outcomes: Vec<crate::relay_chunk::ChunkOutcome> =
Vec::with_capacity(ranges.len());
let mut registered_all: Vec<String> = Vec::new();
for r in &ranges {
if crate::app::chat::turn_cancelled()
|| crate::relay_chunk::should_stop(&outcomes)
{
break;
}
let chunk = reg_requested[r.clone()].to_vec();
match crate::app::events::run_batch_create_subdomains(&chunk).await {
Ok((registered, tx)) => {
registered_all.extend(registered);
outcomes.push(crate::relay_chunk::ChunkOutcome::Landed(tx));
}
Err(e) if e == crate::app::events::NO_VALID_NAMES => {
outcomes.push(crate::relay_chunk::ChunkOutcome::Landed(String::new()));
}
Err(e) => outcomes.push(crate::relay_chunk::classify_failure(e)),
}
}
let fold = crate::relay_chunk::fold_outcomes(&ranges, &outcomes);
if !any_source
&& registered_all.is_empty()
&& fold.unconfirmed.is_empty()
&& fold.unattempted.is_empty()
{
let msg = fold
.chunk_errors
.first()
.map(|(_, e)| e.clone())
.unwrap_or_else(|| crate::app::events::NO_VALID_NAMES.to_string());
return Err(crate::error::Error::other(format!("batch create failed: {msg}")));
}
let states =
crate::batch_apps::item_states(&plan, &fold, &cleaned, ®istered_all);
let mut published: Vec<serde_json::Value> = Vec::new();
let mut publish_failed: Vec<serde_json::Value> = Vec::new();
let mut publish_skipped: Vec<serde_json::Value> = Vec::new();
for (i, st) in states.iter().enumerate() {
let Some(Ok(wasm)) = &compiled[i] else { continue };
let mut skip = |state: &str, reason: String| {
let mut e = batch_name_fields(&cleaned[i], &items[i].name);
e.insert("registration_state".into(), state.into());
e.insert("reason".into(), reason.into());
publish_skipped.push(e.into());
};
let updated = match st {
ItemState::Registered => false,
ItemState::UpdateInPlace => true,
ItemState::CompileFailed(_) => continue,
ItemState::Skipped(r) => {
skip("skipped", format!("not registered ({r}) — app NOT published"));
continue;
}
ItemState::Failed => {
skip(
"failed",
"registration tx failed — the name did not register and \
the app was not published; re-run this item"
.into(),
);
continue;
}
ItemState::Unconfirmed => {
skip(
"unconfirmed",
"registration unconfirmed — if the tx lands, re-publish \
with publish_app_to (or re-run this item: a name you \
own updates in place)"
.into(),
);
continue;
}
ItemState::Unattempted => {
skip(
"unattempted",
"registration never attempted (the batch stopped early) \
— re-run this item to register and publish"
.into(),
);
continue;
}
};
if crate::app::chat::turn_cancelled() {
skip(
if updated { "update_in_place" } else { "registered" },
"stopped by the user before this publish — re-run or \
use publish_app_to"
.into(),
);
continue;
}
let owner = signer_owner.as_deref().unwrap_or_default();
let source = items[i].source.as_deref().unwrap_or_default();
match publish_wasm_face(&cleaned[i], source, wasm, owner).await {
Ok(()) => {
let mut e = batch_name_fields(&cleaned[i], &items[i].name);
e.insert(
"url".into(),
format!("https://{}.localharness.xyz/", cleaned[i]).into(),
);
e.insert("updated".into(), updated.into());
published.push(e.into());
}
Err(err) => {
let mut e = batch_name_fields(&cleaned[i], &items[i].name);
e.insert("error".into(), err.to_string().into());
e.insert("registered".into(), (!updated).into());
publish_failed.push(e.into());
}
}
}
let mut skipped: Vec<&str> = Vec::new();
let mut skipped_reasons: Vec<serde_json::Value> = Vec::new();
let mut unattempted: Vec<&str> = Vec::new();
let mut compile_failed: Vec<serde_json::Value> = Vec::new();
for (i, st) in states.iter().enumerate() {
match st {
ItemState::Skipped(r) => {
skipped.push(&items[i].name);
let mut e = batch_name_fields(&cleaned[i], &items[i].name);
e.insert("reason".into(), r.as_str().into());
skipped_reasons.push(e.into());
}
ItemState::Unattempted => unattempted.push(if cleaned[i].is_empty() {
&items[i].name
} else {
&cleaned[i]
}),
ItemState::CompileFailed(err) => {
let mut e = batch_name_fields(&cleaned[i], &items[i].name);
e.insert("error".into(), err.as_str().into());
compile_failed.push(e.into());
}
_ => {}
}
}
let failed: Vec<serde_json::Value> = fold
.chunk_errors
.iter()
.map(|(ci, err)| serde_json::json!({
"names": reg_requested[ranges[*ci].clone()],
"error": err,
}))
.collect();
let unconfirmed: Vec<serde_json::Value> = fold
.unconfirmed_txs
.iter()
.map(|(ci, tx)| serde_json::json!({
"names": reg_requested[ranges[*ci].clone()],
"tx_hash": tx,
"note": "receipt timed out — the tx may still land; check the \
tx hash before retrying these names",
}))
.collect();
let mut urls: Vec<String> = states
.iter()
.enumerate()
.filter(|(i, st)| {
matches!(st, ItemState::Registered) && items[*i].source.is_none()
})
.map(|(i, _)| format!("https://{}.localharness.xyz/", cleaned[i]))
.collect();
urls.extend(
published.iter().filter_map(|p| p["url"].as_str().map(str::to_string)),
);
Ok(serde_json::json!({
"registered": registered_all,
"skipped": skipped,
"skipped_reasons": skipped_reasons,
"count": registered_all.len(),
"tx_hashes": fold.tx_hashes,
"failed": failed,
"unconfirmed": unconfirmed,
"unattempted": unattempted,
"urls": urls,
"published": published,
"publish_failed": publish_failed,
"publish_skipped": publish_skipped,
"compile_failed": compile_failed,
}))
},
)
}
pub(crate) fn list_subdomains_tool() -> std::sync::Arc<dyn crate::tools::Tool> {
ClosureTool::new(
"list_subdomains",
"List every subdomain owned by this agent's owner (their identity's holdings on \
the registry). Read-only. Use when the user asks what subdomains/agents they have.",
serde_json::json!({ "type": "object", "properties": {} }),
|_args: serde_json::Value, _ctx| async move {
let (_, owner) = crate::app::tenant::current_tenant_owner()
.await
.map_err(crate::error::Error::other)?;
let tokens = crate::app::registry::list_owned_tokens(&owner)
.await
.map_err(crate::error::Error::other)?;
let subdomains: Vec<_> = tokens
.iter()
.map(|t| {
serde_json::json!({
"name": t.name,
"url": format!("https://{}.localharness.xyz/", t.name),
"token_id": t.token_id,
})
})
.collect();
Ok(serde_json::json!({
"owner": owner,
"count": subdomains.len(),
"subdomains": subdomains,
}))
},
)
}
pub(crate) fn discover_agents_tool() -> std::sync::Arc<dyn crate::tools::Tool> {
fn snippet(persona: &str) -> String {
const MAX: usize = 160;
let trimmed = persona.trim();
if trimmed.chars().count() <= MAX {
return trimmed.to_string();
}
let mut s: String = trimmed.chars().take(MAX).collect();
s.push('…');
s
}
ClosureTool::new(
"discover_agents",
"Find peer agents by capability or persona. Read-only registry scan: \
returns the agents whose subdomain NAME or on-chain persona matches \
`query`. MULTI-KEYWORD: the query is split on whitespace and an agent \
matches ANY keyword, ranked by how many it matches (name matches above \
persona matches) — so ONE call with \"game tool puzzle\" replaces a \
sequential call per keyword. Use this to LOCATE an agent to delegate \
to, then call_agent it. Returns { agents: [ { name, persona } ], \
count } (persona is a short preview).",
crate::tool_params::DiscoverAgentsParams::schema(),
|args: serde_json::Value, _ctx| async move {
let query = crate::tool_params::DiscoverAgentsParams::lenient(&args).query;
let matches = crate::app::registry::discover_agents(&query, 100)
.await
.map_err(crate::error::Error::other)?;
let agents: Vec<_> = matches
.iter()
.map(|(name, persona)| {
serde_json::json!({
"name": name,
"persona": snippet(persona),
})
})
.collect();
Ok(serde_json::json!({
"count": agents.len(),
"agents": agents,
}))
},
)
}
pub(crate) fn send_lh_tool() -> std::sync::Arc<dyn crate::tools::Tool> {
let schema = crate::tool_params::SendLhParams::schema();
ClosureTool::new(
"send_lh",
"Transfer real $LH credits from the owner's wallet to a recipient. \
`recipient` is a raw 0x… address OR a subdomain name (funds go to that \
name's on-chain owner). `amount` is a decimal $LH figure (must be > 0). \
MOVES VALUE — the first call does NOT execute: it returns a single-use \
confirmation code (also shown to the owner in the UI). State the \
recipient + amount, ask the owner to TYPE the code, then retry with \
`confirmation` set to it. Returns { amount, recipient (input), \
resolved_recipient, tx_hash }.",
schema,
|args: serde_json::Value, _ctx| async move {
use crate::encoding::parse_token_amount;
let params = crate::tool_params::SendLhParams::lenient(&args);
let recipient_arg = params.recipient.trim().to_string();
let amount_arg = params.amount.trim().to_string();
let amount_wei = parse_token_amount(&amount_arg).ok_or_else(|| {
crate::error::Error::bad_args("send_lh", format!(
"could not parse amount \"{amount_arg}\" — pass a decimal $LH \
figure like \"5\" or \"1.5\""
))
})?;
if amount_wei == 0 {
return Err(crate::error::Error::bad_args(
"send_lh",
"amount must be greater than 0",
));
}
let confirmed = params
.confirmation
.as_deref()
.map(|s| !s.trim().is_empty())
.unwrap_or(false);
if !confirmed {
return Err(crate::error::Error::bad_args(
"send_lh",
"send_lh requires the platform-issued confirmation code",
));
}
let to_hex = resolve_lh_recipient("send_lh", &recipient_arg).await?;
let (_, from) = crate::app::tenant::current_tenant_owner()
.await
.map_err(crate::error::Error::other)?;
let mut calls = Vec::with_capacity(2);
let bridged = match meter_bridge_call(&from, amount_wei).await? {
Some(bridge) => {
calls.push(bridge);
true
}
None => false,
};
calls.push(lh_transfer_call(&to_hex, amount_wei)?);
let amount_display = amount_arg.clone();
let purpose = format!("send {amount_display} $LH to {to_hex}");
let gas = if bridged { 650_000 } else { 500_000 };
let tx_hash =
crate::app::events::run_sponsored_tempo_call(&from, calls, gas, &purpose)
.await
.map_err(|e| crate::error::Error::other(format!("send_lh failed: {e}")))?;
notify_recipient_of_incoming_lh(
recipient_arg.clone(),
to_hex.clone(),
amount_display.clone(),
);
Ok(serde_json::json!({
"amount": amount_display,
"recipient": recipient_arg,
"resolved_recipient": to_hex,
"bridged_from_meter": bridged,
"tx_hash": tx_hash,
}))
},
)
}
pub(crate) fn batch_send_lh_tool() -> std::sync::Arc<dyn crate::tools::Tool> {
let schema = serde_json::json!({
"type": "object",
"properties": {
"transfers": {
"type": "array",
"description": "The transfers to execute. More than 7 are split \
across multiple sponsored transactions automatically (at \
most 7 ride each tx); at most 28 transfers per call — split \
a bigger batch into separate calls.",
"items": {
"type": "object",
"properties": {
"recipient": {
"type": "string",
"description": "0x… address or subdomain name (funds \
go to the name's on-chain owner)."
},
"amount": {
"type": "string",
"description": "Decimal $LH amount, e.g. \"1\" or \
\"0.5\". Must be greater than 0."
}
},
"required": ["recipient", "amount"]
}
},
"confirmation": {
"type": "string",
"description": "Single-use confirmation code. OMIT (or pass \"\") on the \
first call — it returns a challenge code shown to the owner. Show the \
full transfer list, ask the owner to TYPE the code in chat, then \
retry with it. Never invent it; only the platform issues it."
}
},
"required": ["transfers"]
});
ClosureTool::new(
"batch_send_lh",
"Transfer $LH to MULTIPLE recipients in batched on-chain transactions \
— more than 7 transfers are split across multiple sponsored txs \
automatically; at most 28 transfers per call (split a bigger payroll \
into separate calls). Each transfer names a 0x… address or a subdomain \
(paid to its on-chain owner). Far cheaper than repeated send_lh calls. \
MOVES VALUE — the first call does NOT execute: it returns a single-use \
confirmation code (also shown to the owner in the UI). Show the full \
list, ask the owner to TYPE the code, then retry with `confirmation` \
set to it. ONE code for the whole batch. Per-transfer `status` is \
\"landed\", \"failed\" (that chunk's tx FAILED — those transfers did \
NOT move), \"unconfirmed\" (the chunk's receipt TIMED OUT — the tx MAY \
still land; check its tx_hash before re-sending, or you risk paying \
twice; the batch stops there), or \"unattempted\" (never tried — the \
batch stops early after 2 consecutive failed chunks, an unconfirmed \
chunk, or a user Stop). Returns { count, total, transfers: \
[{recipient, resolved, amount, status}], tx_hashes, failed, \
unconfirmed, unattempted }.",
schema,
|args: serde_json::Value, _ctx| async move {
use crate::encoding::parse_token_amount;
let items = args
.get("transfers")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
if items.is_empty() {
return Err(crate::error::Error::bad_args(
"batch_send_lh",
"batch_send_lh: transfers must be a non-empty array",
));
}
if let Some(msg) = crate::relay_chunk::over_batch_limit("batch_send_lh", items.len())
{
return Err(crate::error::Error::bad_args("batch_send_lh", msg));
}
let confirmed = args
.get("confirmation")
.and_then(|v| v.as_str())
.map(|s| !s.trim().is_empty())
.unwrap_or(false);
if !confirmed {
return Err(crate::error::Error::bad_args(
"batch_send_lh",
"batch_send_lh requires the platform-issued confirmation code",
));
}
let mut resolved: Vec<(String, String, u128, String)> =
Vec::with_capacity(items.len());
let mut total_wei: u128 = 0;
for item in &items {
let recipient = item
.get("recipient")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim()
.to_string();
let amount_str = item
.get("amount")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim()
.to_string();
let amount_wei = parse_token_amount(&amount_str).ok_or_else(|| {
crate::error::Error::bad_args("batch_send_lh", format!(
"could not parse amount \"{amount_str}\" for \"{recipient}\""
))
})?;
if amount_wei == 0 {
return Err(crate::error::Error::bad_args("batch_send_lh", format!(
"amount for \"{recipient}\" must be greater than 0"
)));
}
let to_hex = resolve_lh_recipient("batch_send_lh", &recipient).await?;
total_wei = total_wei.checked_add(amount_wei).ok_or_else(|| {
crate::error::Error::bad_args(
"batch_send_lh",
"batch total exceeds the maximum representable amount — lower the amounts",
)
})?;
resolved.push((recipient, to_hex, amount_wei, amount_str));
}
let (_, from) = crate::app::tenant::current_tenant_owner()
.await
.map_err(crate::error::Error::other)?;
let ranges = crate::relay_chunk::chunk_ranges(resolved.len(), true);
let mut outcomes: Vec<crate::relay_chunk::ChunkOutcome> =
Vec::with_capacity(ranges.len());
let mut bridged_any = false;
for r in &ranges {
if crate::app::chat::turn_cancelled()
|| crate::relay_chunk::should_stop(&outcomes)
{
break;
}
let chunk = &resolved[r.clone()];
let chunk_total: u128 = chunk.iter().map(|(_, _, w, _)| *w).sum();
let attempt: Result<(String, bool), String> = async {
let mut calls = Vec::with_capacity(chunk.len() + 1);
let bridged = match meter_bridge_call(&from, chunk_total)
.await
.map_err(|e| e.to_string())?
{
Some(bridge) => {
calls.push(bridge);
true
}
None => false,
};
for (_, to_hex, amount_wei, _) in chunk {
calls.push(
lh_transfer_call(to_hex, *amount_wei).map_err(|e| e.to_string())?,
);
}
let purpose = format!(
"batch-send {} $LH to {} recipients",
crate::app::format_wei_as_test_eth(chunk_total),
chunk.len()
);
let gas = 500_000
+ 80_000 * (chunk.len() as u128 - 1)
+ if bridged { 150_000 } else { 0 };
let tx =
crate::app::events::run_sponsored_tempo_call(&from, calls, gas, &purpose)
.await?;
Ok((tx, bridged))
}
.await;
match attempt {
Ok((tx, bridged)) => {
bridged_any |= bridged;
outcomes.push(crate::relay_chunk::ChunkOutcome::Landed(tx));
}
Err(e) => outcomes.push(crate::relay_chunk::classify_failure(e)),
}
}
let fold = crate::relay_chunk::fold_outcomes(&ranges, &outcomes);
if fold.landed.is_empty()
&& fold.unconfirmed.is_empty()
&& fold.unattempted.is_empty()
{
if let Some((_, e)) = fold.chunk_errors.first() {
return Err(crate::error::Error::other(format!("batch_send_lh failed: {e}")));
}
}
for &i in &fold.landed {
let (recipient, to_hex, _, amount_str) = &resolved[i];
notify_recipient_of_incoming_lh(
recipient.clone(),
to_hex.clone(),
amount_str.clone(),
);
}
let landed_total: u128 = fold.landed.iter().map(|&i| resolved[i].2).sum();
use std::collections::HashSet;
let landed_set: HashSet<usize> = fold.landed.iter().copied().collect();
let failed_set: HashSet<usize> = fold.failed.iter().copied().collect();
let unconfirmed_set: HashSet<usize> = fold.unconfirmed.iter().copied().collect();
let transfers: Vec<serde_json::Value> = resolved
.iter()
.enumerate()
.map(|(i, (recipient, to_hex, _, amount_str))| {
let status = if landed_set.contains(&i) {
"landed"
} else if failed_set.contains(&i) {
"failed"
} else if unconfirmed_set.contains(&i) {
"unconfirmed"
} else {
"unattempted"
};
serde_json::json!({
"recipient": recipient,
"resolved": to_hex,
"amount": amount_str,
"status": status,
})
})
.collect();
let failed: Vec<serde_json::Value> = fold
.chunk_errors
.iter()
.map(|(ci, err)| serde_json::json!({
"recipients": ranges[*ci].clone()
.map(|i| resolved[i].0.clone())
.collect::<Vec<_>>(),
"error": err,
}))
.collect();
let unconfirmed: Vec<serde_json::Value> = fold
.unconfirmed_txs
.iter()
.map(|(ci, tx)| serde_json::json!({
"recipients": ranges[*ci].clone()
.map(|i| resolved[i].0.clone())
.collect::<Vec<_>>(),
"tx_hash": tx,
"note": "receipt timed out — the transfer may still land; check \
the tx hash before re-sending or you risk paying twice",
}))
.collect();
let unattempted: Vec<String> =
fold.unattempted.iter().map(|&i| resolved[i].0.clone()).collect();
Ok(serde_json::json!({
"count": fold.landed.len(),
"total": crate::app::format_wei_as_test_eth(landed_total),
"bridged_from_meter": bridged_any,
"transfers": transfers,
"tx_hashes": fold.tx_hashes,
"failed": failed,
"unconfirmed": unconfirmed,
"unattempted": unattempted,
}))
},
)
}
pub(crate) fn check_balances_tool() -> std::sync::Arc<dyn crate::tools::Tool> {
let schema = serde_json::json!({
"type": "object",
"properties": {}
});
ClosureTool::new(
"check_balances",
"Read this agent's $LH balances: the owner WALLET (pays send_lh and \
x402 agent calls), the chat METER (pays model usage; auto-bridges \
into the wallet when it is short), and this subdomain's token-bound \
account (TBA — where bounty rewards and x402 earnings land). The meter \
splits into a WITHDRAWABLE portion (sendable / bridgeable to the wallet) \
and a LOCKED portion (fiat-minted $LH, spend-only on inference until its \
unlock time) — so a send_lh/bridge that would revert InsufficientCredits \
(LH2024) is visible BEFORE attempting it. Read-only, costs nothing. \
Returns decimal $LH figures plus raw wei.",
schema,
|_args: serde_json::Value, _ctx| async move {
let (name, owner) = crate::app::tenant::current_tenant_owner()
.await
.map_err(crate::error::Error::other)?;
let wallet = crate::app::registry::token_balance_of(&owner)
.await
.unwrap_or(0);
let meter = crate::app::registry::credit_balance_of(&owner)
.await
.unwrap_or(0);
let withdrawable = crate::app::registry::withdrawable_credit_of(&owner)
.await
.unwrap_or(meter);
let meter_locked = meter.saturating_sub(withdrawable);
let (_lock_amt, unlock_at) = crate::app::registry::fiat_locked_of(&owner)
.await
.unwrap_or((0, 0));
let tba_hex = crate::app::registry::tba_of_name(&name)
.await
.ok()
.flatten();
let tba_balance = match &tba_hex {
Some(addr) => crate::app::registry::token_balance_of(addr)
.await
.unwrap_or(0),
None => 0,
};
Ok(serde_json::json!({
"owner_address": owner,
"wallet_lh": crate::app::format_wei_as_test_eth(wallet),
"wallet_wei": wallet.to_string(),
"meter_lh": crate::app::format_wei_as_test_eth(meter),
"meter_wei": meter.to_string(),
"meter_withdrawable_lh": crate::app::format_wei_as_test_eth(withdrawable),
"meter_withdrawable_wei": withdrawable.to_string(),
"meter_locked_lh": crate::app::format_wei_as_test_eth(meter_locked),
"meter_locked_wei": meter_locked.to_string(),
"meter_lock_unlock_at": unlock_at,
"tba_address": tba_hex,
"tba_lh": crate::app::format_wei_as_test_eth(tba_balance),
"tba_wei": tba_balance.to_string(),
"spendable_total_lh": crate::app::format_wei_as_test_eth(
wallet.saturating_add(withdrawable)
),
}))
},
)
}
pub(crate) fn query_balance_tool() -> std::sync::Arc<dyn crate::tools::Tool> {
let schema = crate::tool_params::QueryBalanceParams::schema();
ClosureTool::new(
"query_balance",
"Read the LIVE on-chain $LH balance of ANY agent (by name) or 0x address — \
use this instead of GUESSING a peer's balance. For a name it returns both \
the owner WALLET and the agent's token-bound account (TBA, where earnings \
land); for a raw address, that address's balance. Read-only, costs nothing. \
Decimal $LH plus raw wei.",
schema,
|args: serde_json::Value, _ctx| async move {
let target = crate::tool_params::QueryBalanceParams::lenient(&args)
.target
.trim()
.to_string();
if target.is_empty() {
return Err(crate::error::Error::bad_args(
"query_balance",
"query_balance: target (an agent name or 0x address) is required",
));
}
if target.starts_with("0x") && target.len() == 42 {
let bal = crate::app::registry::token_balance_of(&target)
.await
.unwrap_or(0);
return Ok(serde_json::json!({
"target": target,
"resolved_as": "address",
"lh": crate::app::format_wei_as_test_eth(bal),
"wei": bal.to_string(),
}));
}
let name = target
.trim_end_matches(".localharness.xyz")
.to_lowercase();
let owner = crate::app::registry::owner_of_name(&name)
.await
.ok()
.flatten();
let Some(owner) = owner else {
return Err(crate::error::Error::other(format!(
"query_balance: no agent named '{name}' is registered on-chain"
)));
};
let tba = crate::app::registry::tba_of_name(&name).await.ok().flatten();
let wallet = crate::app::registry::token_balance_of(&owner)
.await
.unwrap_or(0);
let tba_balance = match &tba {
Some(addr) => crate::app::registry::token_balance_of(addr)
.await
.unwrap_or(0),
None => 0,
};
Ok(serde_json::json!({
"target": name,
"resolved_as": "name",
"owner_address": owner,
"wallet_lh": crate::app::format_wei_as_test_eth(wallet),
"wallet_wei": wallet.to_string(),
"tba_address": tba,
"tba_lh": crate::app::format_wei_as_test_eth(tba_balance),
"tba_wei": tba_balance.to_string(),
}))
},
)
}