use serde::{Deserialize, Serialize};
use sha3::{Digest, Keccak256};
use super::*;
#[derive(Serialize)]
pub(crate) struct RpcRequest<'a> {
pub(crate) jsonrpc: &'a str,
pub(crate) id: u32,
pub(crate) method: &'a str,
pub(crate) params: serde_json::Value,
}
#[derive(Deserialize)]
pub(crate) struct RpcResponse {
#[serde(default)]
result: Option<serde_json::Value>,
#[serde(default)]
pub(crate) error: Option<RpcError>,
}
#[derive(Deserialize)]
pub(crate) struct RpcError {
#[allow(dead_code)]
code: i64,
pub(crate) message: String,
}
pub(crate) const RPC_TIMEOUT_MS: u32 = 20_000;
pub(crate) fn read_client() -> reqwest::Client {
#[cfg(not(target_arch = "wasm32"))]
{
reqwest::Client::builder()
.timeout(std::time::Duration::from_millis(RPC_TIMEOUT_MS as u64))
.build()
.unwrap_or_else(|_| reqwest::Client::new())
}
#[cfg(target_arch = "wasm32")]
{
reqwest::Client::new()
}
}
pub(crate) async fn timeout_send<F, T>(label: &str, fut: F) -> Result<T, String>
where
F: std::future::Future<Output = T>,
{
use futures_util::future::{select, Either};
let work = std::pin::pin!(fut);
let timer = std::pin::pin!(sleep_ms(RPC_TIMEOUT_MS));
match select(work, timer).await {
Either::Left((out, _)) => Ok(out),
Either::Right(((), _)) => Err(format!(
"{label}: RPC request timed out after {}s",
RPC_TIMEOUT_MS / 1000
)),
}
}
pub(crate) async fn rpc_value(method: &str, params: serde_json::Value) -> Result<serde_json::Value, String> {
let body = RpcRequest {
jsonrpc: "2.0",
id: 1,
method,
params,
};
let client = read_client();
let parsed: RpcResponse = timeout_send(method, async {
let resp = client
.post(RPC_URL)
.json(&body)
.send()
.await
.map_err(|e| format!("{method} send: {e}"))?;
resp.json::<RpcResponse>()
.await
.map_err(|e| format!("{method} decode: {e}"))
})
.await??;
if let Some(err) = parsed.error {
return Err(format!("{method}: {}", err.message));
}
parsed
.result
.ok_or_else(|| format!("{method} returned no result"))
}
pub(crate) async fn rpc(method: &str, params: serde_json::Value) -> Result<String, String> {
let value = rpc_value(method, params).await?;
value
.as_str()
.map(|s| s.to_string())
.ok_or_else(|| format!("{method}: expected string result"))
}
pub(crate) async fn eth_call(to: &str, data_hex: &str) -> Result<String, String> {
rpc(
"eth_call",
serde_json::json!([{ "to": to, "data": data_hex }, "latest"]),
)
.await
}
pub(crate) async fn read_view(sel: [u8; 4], words: &[[u8; 32]]) -> Result<String, String> {
eth_call(REGISTRY_ADDRESS, &encode_call_hex(sel, words)).await
}
pub async fn is_contract_deployed(address: &str) -> Result<bool, String> {
let _ = parse_eth_address(address)?;
let code = rpc(
"eth_getCode",
serde_json::json!([address, "latest"]),
)
.await?;
let trimmed = code.trim().trim_start_matches("0x");
Ok(!trimmed.is_empty() && !trimmed.chars().all(|c| c == '0'))
}
pub(crate) fn call_uint(sig: &str, id: u64) -> String {
encode_call_hex(selector(sig), &[u256_be(id as u128)])
}
pub(crate) fn decode_address(result_hex: &str) -> Option<String> {
let trimmed = result_hex.trim().trim_start_matches("0x");
if trimmed.len() < 64 {
return None;
}
let addr_hex = &trimmed[trimmed.len() - 40..];
if addr_hex.chars().all(|c| c == '0') {
return None;
}
Some(format!("0x{}", addr_hex.to_lowercase()))
}
pub async fn facet_address_of(diamond: &str, selector: [u8; 4]) -> Result<Option<String>, String> {
let mut arg = [0u8; 32];
arg[..4].copy_from_slice(&selector); let data = encode_call_hex([0xcd, 0xff, 0xac, 0xc6], &[arg]); let result = eth_call(diamond, &data).await?;
Ok(decode_address(&result))
}
pub(crate) fn decode_string(result_hex: &str) -> Option<String> {
let raw = hex_to_bytes(result_hex).ok()?;
if raw.len() < 64 {
return None;
}
let len = u64::from_be_bytes(raw[56..64].try_into().ok()?) as usize;
let end = len.checked_add(64)?;
let body = raw.get(64..end)?;
String::from_utf8(body.to_vec()).ok()
}
pub(crate) async fn eth_call_batch(calls: &[(&str, String)]) -> Result<Vec<Result<String, String>>, String> {
if calls.is_empty() {
return Ok(Vec::new());
}
let batch: Vec<serde_json::Value> = calls
.iter()
.enumerate()
.map(|(i, (to, data))| {
serde_json::json!({
"jsonrpc": "2.0",
"id": i,
"method": "eth_call",
"params": [{ "to": to, "data": data }, "latest"],
})
})
.collect();
let client = read_client();
let parsed: Vec<serde_json::Value> = timeout_send("eth_call batch", async {
let resp = client
.post(RPC_URL)
.json(&serde_json::Value::Array(batch))
.send()
.await
.map_err(|e| format!("eth_call batch send: {e}"))?;
resp.json::<Vec<serde_json::Value>>()
.await
.map_err(|e| format!("eth_call batch decode: {e}"))
})
.await??;
let mut out: Vec<Result<String, String>> = (0..calls.len())
.map(|_| Err("missing batch response".to_string()))
.collect();
for item in parsed {
let Some(idx) = item.get("id").and_then(|v| v.as_u64()).map(|i| i as usize) else {
continue;
};
if idx >= out.len() {
continue;
}
if let Some(err) = item.get("error") {
let msg = err
.get("message")
.and_then(|m| m.as_str())
.unwrap_or("rpc error");
out[idx] = Err(msg.to_string());
} else if let Some(result) = item.get("result").and_then(|r| r.as_str()) {
out[idx] = Ok(result.to_string());
}
}
Ok(out)
}
pub(crate) async fn eth_get_logs(
address: &str,
topics: Vec<serde_json::Value>,
from_block: &str,
) -> Result<Vec<serde_json::Value>, String> {
let result = rpc_value(
"eth_getLogs",
serde_json::json!([{
"address": address,
"topics": topics,
"fromBlock": from_block,
"toBlock": "latest"
}]),
)
.await?;
match result {
serde_json::Value::Array(logs) => Ok(logs),
_ => Ok(Vec::new()),
}
}
pub(crate) async fn eth_get_transaction_count(addr: &str) -> Result<u128, String> {
let hex = rpc(
"eth_getTransactionCount",
serde_json::json!([addr, "pending"]),
)
.await?;
parse_hex_quantity(&hex)
}
pub(crate) async fn eth_gas_price() -> Result<u128, String> {
let hex = rpc("eth_gasPrice", serde_json::json!([])).await?;
parse_hex_quantity(&hex)
}
pub(crate) async fn eth_send_raw_transaction(raw_hex: &str) -> Result<String, String> {
match rpc("eth_sendRawTransaction", serde_json::json!([raw_hex])).await {
Ok(hash) => Ok(hash),
Err(err) => match classify_submit_error(&err) {
SubmitError::Duplicate => {
let bytes = hex_to_bytes(raw_hex)?;
let mut hasher = Keccak256::new();
hasher.update(&bytes);
let digest = hasher.finalize();
Ok(format!("0x{}", bytes_to_hex(&digest)))
}
SubmitError::StaleNonce => Err(format!("{STALE_NONCE_ERR}: {err}")),
SubmitError::Other => Err(err),
},
}
}
pub(crate) const STALE_NONCE_ERR: &str = "stale-nonce";
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum SubmitError {
Duplicate,
StaleNonce,
Other,
}
pub(crate) fn classify_submit_error(err: &str) -> SubmitError {
let lower = err.to_lowercase();
if lower.contains("already known") || lower.contains("already exists") {
SubmitError::Duplicate
} else if lower.contains("nonce too low")
|| lower.contains("replacement transaction underpriced")
|| lower.contains("already imported")
{
SubmitError::StaleNonce
} else {
SubmitError::Other
}
}
pub(crate) const KNOWN_REVERTS: &[(&str, u16, &str)] = {
use crate::error_codes as c;
&[
("NotDue()", c::TX_NOT_DUE, "this job isn't due yet — the scheduler only fires on the interval. Check `localharness jobs` for its next run."),
("StaleNextRun()", c::TX_STALE_NEXT_RUN, "this run was already fired by the scheduler — nothing to do (the on-chain clock already advanced)."),
("SpendExceedsBudget()", c::TX_SPEND_EXCEEDS_BUDGET, "the run would spend more $LH than the job's remaining budget — top it up or it will be marked exhausted."),
("NotScheduler()", c::TX_NOT_SCHEDULER, "only the scheduler worker can record a run — this isn't a user action."),
("NotJobOwner()", c::TX_NOT_JOB_OWNER, "you don't own this job — only its scheduler can cancel/pause/top it up. Check `localharness jobs` under the right `--as` identity."),
("UnknownJob()", c::TX_UNKNOWN_JOB, "no job with that id — list yours with `localharness jobs` (the id is the `#N`)."),
("JobNotActive()", c::TX_JOB_NOT_ACTIVE, "the job is already cancelled or exhausted — there's nothing to cancel. See `localharness jobs`."),
("JobNotPaused()", c::TX_JOB_NOT_PAUSED, "the job isn't paused, so it can't be resumed."),
("UnregisteredTarget()", c::TX_UNREGISTERED_TARGET, "the target isn't a registered agent — run `localharness whoami <target>` to confirm it exists first."),
("ZeroInterval()", c::TX_ZERO_INTERVAL, "the interval is below the 60s minimum the facet allows — use `--every 60s` or more."),
("ZeroRuns()", c::TX_ZERO_RUNS, "max-runs must be at least 1 — drop `--runs 0`."),
("CodeTaken()", c::TX_CODE_TAKEN, "that invite code already exists on-chain — generate a fresh one (`invite create` makes a new code each time)."),
("BadTtl()", c::TX_BAD_TTL, "the invite TTL is outside the allowed 1h..90d window — use e.g. `--ttl 7d`."),
("EscrowCapExceeded()", c::TX_ESCROW_CAP_EXCEEDED, "this would push your locked invite escrow past the per-funder cap — reclaim an expired invite (`invite reclaim <code>`) or use a smaller amount."),
("UnknownInvite()", c::TX_UNKNOWN_INVITE, "no invite matches that code — double-check you copied the full code, including the `inv-` prefix."),
("NotOpen()", c::TX_NOT_OPEN, "this invite was already accepted or reclaimed — it's spent."),
("Expired()", c::TX_EXPIRED, "this invite has expired — it can no longer be accepted, only reclaimed by its funder (`invite reclaim <code>`)."),
("NotYetExpired()", c::TX_NOT_YET_EXPIRED, "this invite hasn't expired yet — reclaim only works AFTER the TTL elapses. Until then it can still be accepted."),
("ZeroBudget()", c::TX_ZERO_BUDGET, "the budget/amount must be greater than 0."),
("ZeroAmount()", c::TX_ZERO_AMOUNT, "the amount must be greater than 0."),
("NotConfigured()", c::TX_NOT_CONFIGURED, "the on-chain credits token isn't configured — this is a platform-side misconfiguration, not your input. Report it via `localharness feedback`."),
("Error(string)", c::TX_REASON_STRING, "the on-chain call reverted with a reason string (decoded above when available)."),
]
};
#[cfg_attr(not(test), allow(dead_code))]
pub(crate) fn decode_known_revert(selector_bytes: [u8; 4]) -> Option<&'static str> {
for (sig, _code, msg) in KNOWN_REVERTS {
if selector(sig) == selector_bytes {
return Some(msg);
}
}
None
}
pub(crate) fn decode_known_revert_coded(selector_bytes: [u8; 4]) -> Option<String> {
for (sig, code, msg) in KNOWN_REVERTS {
if selector(sig) == selector_bytes {
let name = sig.split('(').next().unwrap_or(sig);
return Some(format!("{}: {name} — {msg}", crate::error_codes::fmt_label(*code)));
}
}
None
}
pub(crate) fn decode_revert_data(data: &[u8]) -> Option<String> {
if data.len() < 4 {
return None;
}
let sel: [u8; 4] = [data[0], data[1], data[2], data[3]];
if sel == [0x08, 0xc3, 0x79, 0xa0] {
let label = crate::error_codes::fmt_label(crate::error_codes::TX_REASON_STRING);
let hex = format!("0x{}", bytes_to_hex(&data[4..]));
if let Some(reason) = decode_string(&hex) {
let reason = reason.trim();
if !reason.is_empty() {
let lower = reason.to_ascii_lowercase();
if lower.contains("balance") || lower.contains("allowance") || lower.contains("escrow") {
return Some(format!(
"{label}: {reason} — you likely don't have enough $LH for the escrow. \
Fund it (`localharness redeem <code>` or have another agent \
`send` you $LH), then retry."
));
}
return Some(format!("{label}: {reason}"));
}
}
}
if sel == [0x4e, 0x48, 0x7b, 0x71] {
return Some(format!(
"{}: the contract hit an internal assertion (Panic) — this is a platform bug, \
not your input; please `localharness feedback` it.",
crate::error_codes::fmt_label(crate::error_codes::TX_PANIC)
));
}
decode_known_revert_coded(sel)
}
pub(crate) async fn fetch_revert_reason(tx_hash: &str) -> Option<String> {
let tx = rpc_value("eth_getTransactionByHash", serde_json::json!([tx_hash]))
.await
.ok()?;
let tx = tx.as_object()?;
let to = tx.get("to")?.as_str()?;
let from = tx.get("from")?.as_str()?;
let input = tx.get("input").and_then(|v| v.as_str()).unwrap_or("0x");
let block = tx.get("blockNumber").and_then(|v| v.as_str()).unwrap_or("latest");
let body = RpcRequest {
jsonrpc: "2.0",
id: 1,
method: "eth_call",
params: serde_json::json!([{ "from": from, "to": to, "data": input }, block]),
};
let client = reqwest::Client::new();
let resp = client.post(RPC_URL).json(&body).send().await.ok()?;
let json: serde_json::Value = resp.json().await.ok()?;
let err = json.get("error")?;
let data_hex = err
.get("data")
.and_then(|d| {
d.as_str()
.map(|s| s.to_string())
.or_else(|| d.get("data").and_then(|x| x.as_str()).map(|s| s.to_string()))
})
.filter(|s| s.len() > 2 && s.starts_with("0x"));
if let Some(hex) = data_hex {
if let Ok(bytes) = hex_to_bytes(&hex) {
if let Some(reason) = decode_revert_data(&bytes) {
return Some(reason);
}
}
}
err.get("message").and_then(|m| m.as_str()).and_then(|m| {
let m = m.trim();
if m.is_empty() || m.eq_ignore_ascii_case("execution reverted") {
None
} else {
Some(m.to_string())
}
})
}
pub(crate) const GENERIC_REVERT_HINT: &str = "the transaction reverted on-chain. Common causes: \
not enough $LH for the escrow/cost (fund with `localharness redeem <code>`), \
you don't own the name/job you're acting on, a duplicate/expired/already-spent \
invite, or a not-yet-due job. Run `localharness whoami <name>` / `jobs` to check state.";
pub(crate) async fn wait_for_receipt(tx_hash: &str) -> Result<(), String> {
for _ in 0..30 {
let body = RpcRequest {
jsonrpc: "2.0",
id: 1,
method: "eth_getTransactionReceipt",
params: serde_json::json!([tx_hash]),
};
let client = reqwest::Client::new();
let resp = client
.post(RPC_URL)
.json(&body)
.send()
.await
.map_err(|e| format!("receipt poll: {e}"))?;
let json: serde_json::Value = resp
.json()
.await
.map_err(|e| format!("receipt parse: {e}"))?;
if let Some(receipt) = json.get("result").filter(|v| !v.is_null()) {
let status = receipt
.get("status")
.and_then(|s| s.as_str())
.unwrap_or("");
if status == "0x1" {
return Ok(());
} else if status == "0x0" {
let reason = fetch_revert_reason(tx_hash).await;
return Err(match reason {
Some(r) => format!("tx reverted: {r}\n {GENERIC_REVERT_HINT}\n tx: {tx_hash}"),
None => format!("tx reverted — {GENERIC_REVERT_HINT}\n tx: {tx_hash}"),
});
}
}
sleep_ms(1000).await;
}
Err(format!("receipt timeout for {tx_hash}"))
}
pub(crate) async fn receipt_contract_address(tx_hash: &str) -> Result<String, String> {
let body = RpcRequest {
jsonrpc: "2.0",
id: 1,
method: "eth_getTransactionReceipt",
params: serde_json::json!([tx_hash]),
};
let client = reqwest::Client::new();
let json: serde_json::Value = client
.post(RPC_URL)
.json(&body)
.send()
.await
.map_err(|e| format!("receipt fetch: {e}"))?
.json()
.await
.map_err(|e| format!("receipt parse: {e}"))?;
json.get("result")
.and_then(|r| r.get("contractAddress"))
.and_then(|a| a.as_str())
.map(|s| s.to_string())
.ok_or_else(|| format!("no contractAddress in receipt for {tx_hash}"))
}
pub use crate::runtime::sleep_ms;
#[cfg(target_arch = "wasm32")]
pub(crate) fn log_main_warning(err: &str) {
use wasm_bindgen::JsValue;
web_sys::console::warn_1(&JsValue::from_str(&format!("auto-set MAIN: {err}")));
}
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn log_main_warning(_err: &str) {
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn decode_known_revert_maps_facet_errors() {
let cases = [
("NotDue()", "due"),
("UnknownJob()", "no job"),
("NotJobOwner()", "don't own"),
("UnregisteredTarget()", "registered agent"),
("CodeTaken()", "already exists"),
("NotYetExpired()", "hasn't expired"),
("Expired()", "expired"),
("NotOpen()", "already accepted or reclaimed"),
("BadTtl()", "1h..90d"),
("EscrowCapExceeded()", "escrow"),
("UnknownInvite()", "no invite"),
];
for (sig, needle) in cases {
let sel = selector(sig);
let msg = decode_known_revert(sel)
.unwrap_or_else(|| panic!("no mapping for {sig}"));
assert!(
msg.to_lowercase().contains(needle),
"message for {sig} ({msg:?}) should mention {needle:?}"
);
}
assert_eq!(decode_known_revert([0xde, 0xad, 0xbe, 0xef]), None);
}
#[test]
fn classify_submit_error_separates_duplicate_from_stale_nonce() {
assert_eq!(classify_submit_error("already known"), SubmitError::Duplicate);
assert_eq!(
classify_submit_error("transaction already exists in pool"),
SubmitError::Duplicate
);
assert_eq!(
classify_submit_error("nonce too low: next nonce 44, tx nonce 43"),
SubmitError::StaleNonce
);
assert_eq!(
classify_submit_error("replacement transaction underpriced"),
SubmitError::StaleNonce
);
assert_eq!(
classify_submit_error("failed to decode signed transaction"),
SubmitError::Other
);
assert_eq!(classify_submit_error("gas price is less than basefee"), SubmitError::Other);
}
#[test]
fn decode_known_revert_selector_bytes_are_keccak_of_signature() {
let not_due = selector("NotDue()");
let hex: String = not_due.iter().map(|b| format!("{b:02x}")).collect();
assert_eq!(hex, "47a2375f");
assert!(decode_known_revert(not_due).is_some());
}
#[test]
fn decode_revert_data_decodes_error_string_envelope() {
let reason = b"schedule: escrow failed";
let mut data = vec![0x08, 0xc3, 0x79, 0xa0];
data.extend_from_slice(&u256_be(0x20)); data.extend_from_slice(&u256_be(reason.len() as u128)); let mut padded = reason.to_vec();
padded.resize(padded.len().div_ceil(32) * 32, 0);
data.extend_from_slice(&padded);
let out = decode_revert_data(&data).expect("decodes Error(string)");
assert!(out.contains("schedule: escrow failed"), "got {out:?}");
let bal = b"ERC20: transfer amount exceeds balance";
let mut d2 = vec![0x08, 0xc3, 0x79, 0xa0];
d2.extend_from_slice(&u256_be(0x20));
d2.extend_from_slice(&u256_be(bal.len() as u128));
let mut p2 = bal.to_vec();
p2.resize(p2.len().div_ceil(32) * 32, 0);
d2.extend_from_slice(&p2);
let out2 = decode_revert_data(&d2).expect("decodes balance error");
assert!(out2.to_lowercase().contains("$lh"), "should suggest funding: {out2:?}");
}
#[test]
fn decode_known_revert_coded_prefixes_lh2xxx_and_name() {
let cases = [
("SpendExceedsBudget()", "LH2003", "SpendExceedsBudget"),
("NotScheduler()", "LH2004", "NotScheduler"),
("NotDue()", "LH2001", "NotDue"),
("CodeTaken()", "LH2012", "CodeTaken"),
("Expired()", "LH2017", "Expired"),
];
for (sig, code, name) in cases {
let out = decode_known_revert_coded(selector(sig))
.unwrap_or_else(|| panic!("no coded mapping for {sig}"));
assert!(out.starts_with(code), "expected {code} prefix, got {out:?}");
assert!(out.contains(name), "expected name {name} in {out:?}");
}
assert_eq!(decode_known_revert_coded([0xde, 0xad, 0xbe, 0xef]), None);
}
#[test]
fn decode_revert_data_maps_custom_error_and_handles_empty() {
let sel = selector("NotYetExpired()");
let out = decode_revert_data(&sel).expect("maps custom error");
assert!(out.to_lowercase().contains("hasn't expired"), "got {out:?}");
assert!(out.starts_with("LH2018"), "expected the LH2018 code prefix, got {out:?}");
assert_eq!(decode_revert_data(&[]), None);
assert_eq!(decode_revert_data(&[0x01, 0x02]), None);
assert_eq!(decode_revert_data(&[0xde, 0xad, 0xbe, 0xef]), None);
}
#[test]
fn decode_string_edge_cases() {
assert_eq!(decode_string("0x"), None);
assert_eq!(decode_string(&format!("0x{Z}")), None);
let huge = format!("0x{}{}", word_usize(0x20), word_u64_max());
assert_eq!(decode_string(&huge), None);
let trunc = format!("0x{}{}", word_usize(0x20), word_usize(64));
assert_eq!(decode_string(&trunc), None);
let ok = format!(
"0x{}{}{}",
word_usize(0x20),
word_usize(2),
"6869000000000000000000000000000000000000000000000000000000000000"
);
assert_eq!(decode_string(&ok).as_deref(), Some("hi"));
}
#[test]
fn decode_address_edge_cases() {
assert_eq!(decode_address("0x"), None);
assert_eq!(decode_address("0x00"), None);
assert_eq!(decode_address(&format!("0x{Z}")), None);
let w = format!("0x{}", "0".repeat(24) + "1111111111111111111111111111111111111111");
assert_eq!(
decode_address(&w).as_deref(),
Some("0x1111111111111111111111111111111111111111")
);
}
}