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 prefund_lh = params.prefund_lh.as_deref();
let cleaned = crate::subdomain::validate(name).map_err(|why| {
crate::error::Error::bad_args("create_subdomain", format!("invalid subdomain name: {why}"))
})?;
match source {
Some(src) => create_subdomain_with_app(&cleaned, src, persona, prefund_lh).await,
None => create_subdomain_name_only(&cleaned, persona, prefund_lh).await,
}
},
)
}
async fn create_subdomain_name_only(
cleaned: &str,
persona: Option<&str>,
prefund_lh: Option<&str>,
) -> 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 want_prefund = prefund_lh
.map(|p| {
let t = p.trim();
!t.is_empty() && t != "0"
})
.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 || want_prefund {
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(
"create_subdomain",
&owner,
token_id,
cleaned,
persona,
prefund_lh,
)
.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_lh: Option<&str>,
) -> 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("create_subdomain", &owner, token_id, cleaned, persona, prefund_lh)
.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)
}
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::rustlite::compile(source).map_err(|e| {
crate::error::Error::other(format!("compile failed: {}", e.render(source)))
})?;
if wasm.len() > crate::app::registry::APP_STORE_MAX_WASM_BYTES {
return Err(crate::error::Error::other(format!(
"app wasm too large to publish: {} bytes (max {})",
wasm.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");
crate::app::registry::publish_app_to_store(name, &token, &wasm, source)
.await
.map_err(|e| crate::error::Error::other(format!("publish failed: {e}")))?;
Ok(wasm)
}
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}"))),
}
},
)
}
const RELAY_MAX_CALLS_PER_TX: usize = 8;
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."
},
"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; \
with `names`, only that subset. 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. \
Always refuses your MAIN. Returns the released names + tx hash.",
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 targets.len() > RELAY_MAX_CALLS_PER_TX {
return Err(crate::error::Error::bad_args("bulk_release_subdomains", format!(
"too many to release in one tx: {} (max {RELAY_MAX_CALLS_PER_TX}) — \
pass a `names` subset of at most {RELAY_MAX_CALLS_PER_TX}",
targets.len()
)));
}
match crate::app::events::run_bulk_release(&targets).await {
Ok((released, tx)) => Ok(serde_json::json!({
"released": released,
"count": released.len(),
"tx_hash": tx,
})),
Err(e) => Err(crate::error::Error::other(format!("bulk release failed: {e}"))),
}
},
)
}
pub(crate) fn batch_create_subdomains_tool() -> std::sync::Arc<dyn crate::tools::Tool> {
let schema = crate::tool_params::BatchCreateSubdomainsParams::schema();
ClosureTool::new(
"batch_create_subdomains",
"Register MANY <name>.localharness.xyz subdomains on-chain in a SINGLE \
sponsored transaction. PREFER THIS over calling create_subdomain in a \
loop when registering more than one name — it is one tx, not N. The \
owner's master wallet ends up holding every resulting ERC-721 NFT. \
Taken or invalid names are skipped (not an error) and listed in \
`skipped`. Max 7 names per call. Returns { registered, skipped, \
count, tx_hash, urls }.",
schema,
|args: serde_json::Value, _ctx| async move {
const MAX_BATCH_CREATE: usize = RELAY_MAX_CALLS_PER_TX - 1;
let requested: Vec<String> = crate::tool_params::BatchCreateSubdomainsParams::lenient(&args)
.names
.iter()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
if requested.is_empty() {
return Err(crate::error::Error::bad_args("batch_create_subdomains", "names cannot be empty"));
}
if requested.len() > MAX_BATCH_CREATE {
return Err(crate::error::Error::bad_args("batch_create_subdomains", format!(
"too many names: {} (max {MAX_BATCH_CREATE} per batch) — \
split into multiple calls",
requested.len()
)));
}
match crate::app::events::run_batch_create_subdomains(&requested).await {
Ok((registered, tx)) => {
let skipped: Vec<&String> = requested
.iter()
.filter(|r| {
let c = crate::app::tenant::sanitize(r);
!registered.iter().any(|reg| reg == &c)
})
.collect();
Ok(serde_json::json!({
"registered": registered,
"skipped": skipped,
"count": registered.len(),
"tx_hash": tx,
"urls": registered.iter()
.map(|n| format!("https://{n}.localharness.xyz/"))
.collect::<Vec<_>>(),
}))
}
Err(e) => Err(crate::error::Error::other(format!(
"batch create failed: {e}"
))),
}
},
)
}
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": "Up to 7 transfers, executed atomically in one \
on-chain transaction.",
"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 ONE on-chain transaction (up \
to 7). 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. Returns { count, total, transfers: \
[{recipient, resolved, amount}], tx_hash }.",
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 items.len() > RELAY_MAX_CALLS_PER_TX - 1 {
return Err(crate::error::Error::bad_args(
"batch_send_lh",
format!("at most {} transfers per batch", RELAY_MAX_CALLS_PER_TX - 1),
));
}
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 — split the batch",
)
})?;
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 mut calls = Vec::with_capacity(resolved.len() + 1);
let bridged = match meter_bridge_call(&from, total_wei).await? {
Some(bridge) => {
calls.push(bridge);
true
}
None => false,
};
for (_, to_hex, amount_wei, _) in &resolved {
calls.push(lh_transfer_call(to_hex, *amount_wei)?);
}
let purpose = format!(
"batch-send {} $LH to {} recipients",
crate::app::format_wei_as_test_eth(total_wei),
resolved.len()
);
let gas = 500_000
+ 80_000 * (resolved.len() as u128 - 1)
+ if bridged { 150_000 } else { 0 };
let tx_hash =
crate::app::events::run_sponsored_tempo_call(&from, calls, gas, &purpose)
.await
.map_err(|e| {
crate::error::Error::other(format!("batch_send_lh failed: {e}"))
})?;
for (recipient, to_hex, _, amount_str) in &resolved {
notify_recipient_of_incoming_lh(
recipient.clone(),
to_hex.clone(),
amount_str.clone(),
);
}
let transfers: Vec<serde_json::Value> = resolved
.iter()
.map(|(recipient, to_hex, _, amount_str)| {
serde_json::json!({
"recipient": recipient,
"resolved": to_hex,
"amount": amount_str,
})
})
.collect();
Ok(serde_json::json!({
"count": transfers.len(),
"total": crate::app::format_wei_as_test_eth(total_wei),
"bridged_from_meter": bridged,
"transfers": transfers,
"tx_hash": tx_hash,
}))
},
)
}
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(),
}))
},
)
}