use k256::ecdsa::SigningKey;
use serde::{Deserialize, Serialize};
use sha3::{Digest, Keccak256};
use crate::wallet;
pub const RPC_URL: &str = "https://rpc.moderato.tempo.xyz";
pub const REGISTRY_ADDRESS: &str = "0x6c31c01e10C44f4813FffDC7D5e671c1b26Da30c";
pub const CHAIN_ID: u64 = 42431;
pub const BOOTSTRAP_FAUCET_ADDRESS: &str = "0x0000000000000000000000000000000000000000";
pub const LOCALHARNESS_TOKEN_ADDRESS: &str = "0x90B84c7234Aae89BadA7f69160B9901B9bc37B17";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Status {
Unknown,
Available,
Taken { agent_id: u64 },
}
#[derive(Debug, Clone)]
pub struct OwnedToken {
pub token_id: u64,
pub name: String,
pub tba: Option<String>,
}
pub async fn list_owned_tokens(owner_hex: &str) -> Result<Vec<OwnedToken>, String> {
if REGISTRY_ADDRESS == zero_address() {
return Ok(Vec::new());
}
let total = next_id().await?;
if total <= 1 {
return Ok(Vec::new());
}
let owner_lower = owner_hex.to_lowercase();
let owner_calls: Vec<(&str, String)> = (1..total)
.map(|id| (REGISTRY_ADDRESS, call_uint("ownerOfId(uint256)", id)))
.collect();
let owners = eth_call_batch(&owner_calls).await?;
let my_ids: Vec<u64> = owners
.iter()
.enumerate()
.filter_map(|(i, res)| {
let addr = decode_address(res.as_ref().ok()?)?;
(addr == owner_lower).then_some((i as u64) + 1)
})
.collect();
if my_ids.is_empty() {
return Ok(Vec::new());
}
let name_calls: Vec<(&str, String)> = my_ids
.iter()
.map(|&id| (REGISTRY_ADDRESS, call_uint("nameOfId(uint256)", id)))
.collect();
let tba_calls: Vec<(&str, String)> = my_ids
.iter()
.map(|&id| (REGISTRY_ADDRESS, call_uint("tokenBoundAccount(uint256)", id)))
.collect();
let names = eth_call_batch(&name_calls).await?;
let tbas = eth_call_batch(&tba_calls).await?;
let mut out: Vec<OwnedToken> = Vec::with_capacity(my_ids.len());
for (k, &id) in my_ids.iter().enumerate() {
let name = names
.get(k)
.and_then(|r| r.as_ref().ok())
.and_then(|h| decode_string(h))
.unwrap_or_default();
if name.is_empty() {
continue;
}
let tba = tbas
.get(k)
.and_then(|r| r.as_ref().ok())
.and_then(|h| decode_address(h));
out.push(OwnedToken {
token_id: id,
name,
tba,
});
}
out.reverse();
Ok(out)
}
async fn next_id() -> Result<u64, String> {
let calldata = format!("0x{}", bytes_to_hex(&selector("nextId()")));
let result_hex = eth_call(REGISTRY_ADDRESS, &calldata).await?;
decode_u256_as_u64(&result_hex)
}
pub async fn subdomain_count() -> Result<u64, String> {
Ok(next_id().await?.saturating_sub(1))
}
pub async fn name_of_id(id: u64) -> Result<String, String> {
let mut data = Vec::with_capacity(4 + 32);
data.extend_from_slice(&selector("nameOfId(uint256)"));
data.extend_from_slice(&u256_be(id as u128));
let calldata = format!("0x{}", bytes_to_hex(&data));
let result_hex = eth_call(REGISTRY_ADDRESS, &calldata).await?;
let raw = hex_to_bytes(&result_hex)?;
if raw.len() < 64 {
return Err(format!("nameOfId: short response {} bytes", raw.len()));
}
let len = u64::from_be_bytes(raw[56..64].try_into().map_err(|e: std::array::TryFromSliceError| e.to_string())?) as usize;
let end = len
.checked_add(64)
.filter(|&end| end <= raw.len())
.ok_or_else(|| format!("nameOfId: truncated body (len {}, have {})", len, raw.len()))?;
String::from_utf8(raw[64..end].to_vec()).map_err(|e| e.to_string())
}
pub async fn tba_of_token_id(token_id: u64) -> Result<Option<String>, String> {
if REGISTRY_ADDRESS == zero_address() {
return Ok(None);
}
let mut data = Vec::with_capacity(4 + 32);
data.extend_from_slice(&selector("tokenBoundAccount(uint256)"));
data.extend_from_slice(&u256_be(token_id as u128));
let calldata = format!("0x{}", bytes_to_hex(&data));
let result_hex = match eth_call(REGISTRY_ADDRESS, &calldata).await {
Ok(h) => h,
Err(err) => {
if err.contains("nonexistent token") || err.contains("registry unset") {
return Ok(None);
}
return Err(err);
}
};
let trimmed = result_hex.trim().trim_start_matches("0x");
if trimmed.len() < 64 {
return Err(format!("tokenBoundAccount: short response {trimmed}"));
}
let addr_hex = &trimmed[trimmed.len() - 40..];
if addr_hex.chars().all(|c| c == '0') {
return Ok(None);
}
Ok(Some(format!("0x{}", addr_hex.to_lowercase())))
}
pub async fn tba_of_name(name: &str) -> Result<Option<String>, String> {
if REGISTRY_ADDRESS == zero_address() {
return Ok(None);
}
let calldata = encode_string_call("tokenBoundAccountByName(string)", name);
let result_hex = match eth_call(REGISTRY_ADDRESS, &calldata).await {
Ok(h) => h,
Err(err) => {
if err.contains("name unregistered") || err.contains("nonexistent token") {
return Ok(None);
}
return Err(err);
}
};
let trimmed = result_hex.trim().trim_start_matches("0x");
if trimmed.len() < 64 {
return Err(format!("tokenBoundAccountByName: short response {trimmed}"));
}
let addr_hex = &trimmed[trimmed.len() - 40..];
if addr_hex.chars().all(|c| c == '0') {
return Ok(None);
}
Ok(Some(format!("0x{}", addr_hex.to_lowercase())))
}
pub async fn owner_of_name(name: &str) -> Result<Option<String>, String> {
if REGISTRY_ADDRESS == zero_address() {
return Ok(None);
}
let calldata = encode_owner_of_name(name);
let result_hex = eth_call(REGISTRY_ADDRESS, &calldata).await?;
let trimmed = result_hex.trim().trim_start_matches("0x");
if trimmed.len() < 64 {
return Err(format!("ownerOfName: short response {trimmed}"));
}
let addr_hex = &trimmed[trimmed.len() - 40..];
if addr_hex.chars().all(|c| c == '0') {
return Ok(None);
}
Ok(Some(format!("0x{}", addr_hex.to_lowercase())))
}
fn encode_owner_of_name(name: &str) -> String {
encode_string_call("ownerOfName(string)", name)
}
fn encode_string_call(signature: &str, value: &str) -> String {
let sel = selector(signature);
let bytes = value.as_bytes();
let len = bytes.len();
let padded_len = len.div_ceil(32) * 32;
let mut buf = Vec::with_capacity(4 + 32 + 32 + padded_len);
buf.extend_from_slice(&sel);
buf.extend_from_slice(&u256_be(0x20));
buf.extend_from_slice(&u256_be(len as u128));
buf.extend_from_slice(bytes);
buf.resize(4 + 32 + 32 + padded_len, 0);
let mut out = String::with_capacity(2 + buf.len() * 2);
out.push_str("0x");
for b in &buf {
out.push_str(&format!("{b:02x}"));
}
out
}
pub async fn check_name(name: &str) -> Result<Status, String> {
if REGISTRY_ADDRESS == zero_address() {
return Ok(Status::Unknown);
}
let calldata = encode_id_of_name(name);
let result_hex = eth_call(REGISTRY_ADDRESS, &calldata).await?;
let id = decode_u256_as_u64(&result_hex)?;
Ok(if id == 0 {
Status::Available
} else {
Status::Taken { agent_id: id }
})
}
pub async fn id_of_name(name: &str) -> Result<u64, String> {
if REGISTRY_ADDRESS == zero_address() {
return Ok(0);
}
let calldata = encode_id_of_name(name);
let result_hex = eth_call(REGISTRY_ADDRESS, &calldata).await?;
decode_u256_as_u64(&result_hex)
}
pub async fn list_recent_agents(limit: u64) -> Result<Vec<(u64, String)>, String> {
let next = next_id().await?;
if next <= 1 {
return Ok(Vec::new());
}
let max_id = next - 1;
let start = max_id.saturating_sub(limit.saturating_sub(1)).max(1);
let mut out = Vec::new();
let mut id = max_id;
loop {
if let Ok(name) = name_of_id(id).await {
if !name.is_empty() {
out.push((id, name));
}
}
if id <= start {
break;
}
id -= 1;
}
Ok(out)
}
pub fn rank_agent_matches(agents: &[(String, String)], query: &str) -> Vec<(String, String)> {
let q = query.trim().to_lowercase();
if q.is_empty() {
return agents.to_vec();
}
let mut name_hits = Vec::new();
let mut persona_hits = Vec::new();
for (name, persona) in agents {
if name.to_lowercase().contains(&q) {
name_hits.push((name.clone(), persona.clone()));
} else if persona.to_lowercase().contains(&q) {
persona_hits.push((name.clone(), persona.clone()));
}
}
name_hits.extend(persona_hits);
name_hits
}
pub async fn discover_agents(query: &str, scan: u64) -> Result<Vec<(String, String)>, String> {
let agents = list_recent_agents(scan).await?;
if agents.is_empty() {
return Ok(Vec::new());
}
let ids: Vec<u64> = agents.iter().map(|(id, _)| *id).collect();
let personas = personas_of(&ids).await;
let pairs: Vec<(String, String)> = agents
.into_iter()
.zip(personas)
.map(|((_, name), persona)| (name, persona.unwrap_or_default()))
.collect();
Ok(rank_agent_matches(&pairs, query))
}
fn app_metadata_key() -> [u8; 32] {
use sha3::{Digest, Keccak256};
let digest = Keccak256::digest(b"localharness.app.wasm");
let mut out = [0u8; 32];
out.copy_from_slice(&digest);
out
}
pub async fn app_wasm_of(token_id: u64) -> Result<Option<Vec<u8>>, String> {
let key = app_metadata_key();
let mut data = Vec::with_capacity(4 + 64);
data.extend_from_slice(&selector("metadata(uint256,bytes32)"));
data.extend_from_slice(&u256_be(token_id as u128));
data.extend_from_slice(&key);
let calldata = format!("0x{}", bytes_to_hex(&data));
let result_hex = eth_call(REGISTRY_ADDRESS, &calldata).await?;
let bytes = hex_to_bytes(&result_hex)?;
if bytes.len() < 64 {
return Ok(None);
}
let mut len_buf = [0u8; 8];
len_buf.copy_from_slice(&bytes[56..64]);
let len = u64::from_be_bytes(len_buf) as usize;
if len == 0 {
return Ok(None);
}
let payload = len
.checked_add(64)
.and_then(|end| bytes.get(64..end))
.ok_or_else(|| "app wasm truncated".to_string())?;
Ok(Some(payload.to_vec()))
}
pub fn encode_set_app_wasm(token_id: u64, wasm: &[u8]) -> Vec<u8> {
let key = app_metadata_key();
let len = wasm.len();
let padded = len.div_ceil(32) * 32;
let mut buf = Vec::with_capacity(4 + 96 + 32 + padded);
buf.extend_from_slice(&selector("setMetadata(uint256,bytes32,bytes)"));
buf.extend_from_slice(&u256_be(token_id as u128)); buf.extend_from_slice(&key); buf.extend_from_slice(&u256_be(0x60)); buf.extend_from_slice(&u256_be(len as u128)); buf.extend_from_slice(wasm);
buf.resize(4 + 96 + 32 + padded, 0); buf
}
fn gemini_key_metadata_key() -> [u8; 32] {
use sha3::{Digest, Keccak256};
let digest = Keccak256::digest(b"localharness.gemini_key.enc");
let mut out = [0u8; 32];
out.copy_from_slice(&digest);
out
}
pub async fn gemini_key_of(token_id: u64) -> Result<Option<Vec<u8>>, String> {
let mut data = Vec::with_capacity(4 + 64);
data.extend_from_slice(&selector("metadata(uint256,bytes32)"));
data.extend_from_slice(&u256_be(token_id as u128));
data.extend_from_slice(&gemini_key_metadata_key());
let calldata = format!("0x{}", bytes_to_hex(&data));
let result_hex = eth_call(REGISTRY_ADDRESS, &calldata).await?;
let bytes = hex_to_bytes(&result_hex)?;
if bytes.len() < 64 {
return Ok(None);
}
let mut len_buf = [0u8; 8];
len_buf.copy_from_slice(&bytes[56..64]);
let len = u64::from_be_bytes(len_buf) as usize;
if len == 0 {
return Ok(None);
}
let payload = len
.checked_add(64)
.and_then(|end| bytes.get(64..end))
.ok_or_else(|| "gemini key ciphertext truncated".to_string())?;
Ok(Some(payload.to_vec()))
}
pub fn encode_set_gemini_key(token_id: u64, ciphertext: &[u8]) -> Vec<u8> {
let key = gemini_key_metadata_key();
let len = ciphertext.len();
let padded = len.div_ceil(32) * 32;
let mut buf = Vec::with_capacity(4 + 96 + 32 + padded);
buf.extend_from_slice(&selector("setMetadata(uint256,bytes32,bytes)"));
buf.extend_from_slice(&u256_be(token_id as u128));
buf.extend_from_slice(&key);
buf.extend_from_slice(&u256_be(0x60));
buf.extend_from_slice(&u256_be(len as u128));
buf.extend_from_slice(ciphertext);
buf.resize(4 + 96 + 32 + padded, 0);
buf
}
fn keccak_key(label: &[u8]) -> [u8; 32] {
use sha3::{Digest, Keccak256};
let digest = Keccak256::digest(label);
let mut out = [0u8; 32];
out.copy_from_slice(&digest);
out
}
async fn metadata_bytes_of(token_id: u64, key: [u8; 32]) -> Result<Option<Vec<u8>>, String> {
let mut data = Vec::with_capacity(4 + 64);
data.extend_from_slice(&selector("metadata(uint256,bytes32)"));
data.extend_from_slice(&u256_be(token_id as u128));
data.extend_from_slice(&key);
let calldata = format!("0x{}", bytes_to_hex(&data));
let result_hex = eth_call(REGISTRY_ADDRESS, &calldata).await?;
let bytes = hex_to_bytes(&result_hex)?;
if bytes.len() < 64 {
return Ok(None);
}
let mut len_buf = [0u8; 8];
len_buf.copy_from_slice(&bytes[56..64]);
let len = u64::from_be_bytes(len_buf) as usize;
if len == 0 {
return Ok(None);
}
let payload = len
.checked_add(64)
.and_then(|end| bytes.get(64..end))
.ok_or_else(|| "metadata truncated".to_string())?;
Ok(Some(payload.to_vec()))
}
fn encode_set_metadata_bytes(token_id: u64, key: [u8; 32], payload: &[u8]) -> Vec<u8> {
let len = payload.len();
let padded = len.div_ceil(32) * 32;
let mut buf = Vec::with_capacity(4 + 96 + 32 + padded);
buf.extend_from_slice(&selector("setMetadata(uint256,bytes32,bytes)"));
buf.extend_from_slice(&u256_be(token_id as u128));
buf.extend_from_slice(&key);
buf.extend_from_slice(&u256_be(0x60));
buf.extend_from_slice(&u256_be(len as u128));
buf.extend_from_slice(payload);
buf.resize(4 + 96 + 32 + padded, 0);
buf
}
const PUBLIC_FACE_LABEL: &[u8] = b"localharness.public_face";
const PUBLIC_HTML_LABEL: &[u8] = b"localharness.public.html";
pub async fn public_face_of(token_id: u64) -> Result<Option<String>, String> {
match metadata_bytes_of(token_id, keccak_key(PUBLIC_FACE_LABEL)).await? {
Some(b) => Ok(String::from_utf8(b)
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())),
None => Ok(None),
}
}
pub fn encode_set_public_face(token_id: u64, choice: &str) -> Vec<u8> {
encode_set_metadata_bytes(token_id, keccak_key(PUBLIC_FACE_LABEL), choice.as_bytes())
}
pub async fn public_html_of(token_id: u64) -> Result<Option<Vec<u8>>, String> {
metadata_bytes_of(token_id, keccak_key(PUBLIC_HTML_LABEL)).await
}
pub fn encode_set_public_html(token_id: u64, html: &[u8]) -> Vec<u8> {
encode_set_metadata_bytes(token_id, keccak_key(PUBLIC_HTML_LABEL), html)
}
const PERSONA_LABEL: &[u8] = b"localharness.persona";
pub async fn persona_of(token_id: u64) -> Result<Option<String>, String> {
match metadata_bytes_of(token_id, keccak_key(PERSONA_LABEL)).await? {
Some(b) => Ok(String::from_utf8(b)
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())),
None => Ok(None),
}
}
pub fn encode_set_persona(token_id: u64, persona: &str) -> Vec<u8> {
encode_set_metadata_bytes(token_id, keccak_key(PERSONA_LABEL), persona.as_bytes())
}
pub async fn personas_of(token_ids: &[u64]) -> Vec<Option<String>> {
if token_ids.is_empty() || REGISTRY_ADDRESS == zero_address() {
return token_ids.iter().map(|_| None).collect();
}
let key = keccak_key(PERSONA_LABEL);
let calls: Vec<(&str, String)> = token_ids
.iter()
.map(|&id| (REGISTRY_ADDRESS, call_metadata(id, key)))
.collect();
match eth_call_batch(&calls).await {
Ok(results) => results
.iter()
.map(|r| {
r.as_ref()
.ok()
.and_then(|hex| decode_metadata_bytes(hex))
.and_then(|b| String::from_utf8(b).ok())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
})
.collect(),
Err(_) => token_ids.iter().map(|_| None).collect(),
}
}
fn call_metadata(token_id: u64, key: [u8; 32]) -> String {
let mut data = Vec::with_capacity(4 + 64);
data.extend_from_slice(&selector("metadata(uint256,bytes32)"));
data.extend_from_slice(&u256_be(token_id as u128));
data.extend_from_slice(&key);
format!("0x{}", bytes_to_hex(&data))
}
fn decode_metadata_bytes(result_hex: &str) -> Option<Vec<u8>> {
let bytes = hex_to_bytes(result_hex).ok()?;
if bytes.len() < 64 {
return None;
}
let mut len_buf = [0u8; 8];
len_buf.copy_from_slice(&bytes[56..64]);
let len = u64::from_be_bytes(len_buf) as usize;
if len == 0 {
return None;
}
len.checked_add(64)
.and_then(|end| bytes.get(64..end))
.map(|s| s.to_vec())
}
const CAPABILITY_LABEL: &[u8] = b"localharness.capability";
pub fn encode_set_capability(token_id: u64, payload: &[u8]) -> Vec<u8> {
let commitment = keccak_key(payload); encode_set_metadata_bytes(token_id, keccak_key(CAPABILITY_LABEL), &commitment)
}
pub async fn capability_descriptor_of(token_id: u64) -> Result<Option<[u8; 32]>, String> {
match metadata_bytes_of(token_id, keccak_key(CAPABILITY_LABEL)).await? {
Some(b) if b.len() == 32 => {
let mut out = [0u8; 32];
out.copy_from_slice(&b);
Ok(Some(out))
}
Some(_) => Err("capability commitment is not 32 bytes".to_string()),
None => Ok(None),
}
}
pub async fn verify_descriptor(token_id: u64, served_payload: &[u8]) -> Result<bool, String> {
match capability_descriptor_of(token_id).await? {
Some(commitment) => Ok(keccak_key(served_payload) == commitment),
None => Ok(false),
}
}
pub const CREDIT_PROXY_URL: &str = "https://proxy-tau-ten-15.vercel.app/";
pub fn proxy_auth_token(signer: &SigningKey, now_secs: u64) -> String {
let addr = format!("0x{}", bytes_to_hex(&crate::wallet::address(signer)));
let msg = format!("localharness-proxy:{addr}:{now_secs}");
let sig = crate::wallet::personal_sign(signer, msg.as_bytes());
format!("{addr}:{now_secs}:0x{}", bytes_to_hex(&sig))
}
pub async fn claim_name(signer: &SigningKey, name: &str) -> Result<String, String> {
if REGISTRY_ADDRESS == zero_address() {
return Err("registry not deployed".into());
}
let from = wallet::address(signer);
let from_hex = address_to_hex(&from);
let nonce = eth_get_transaction_count(&from_hex).await?;
let gas_price = eth_gas_price().await?;
let calldata_hex = encode_register(name);
let gas_limit = eth_estimate_gas(&from_hex, REGISTRY_ADDRESS, &calldata_hex).await?;
let calldata_bytes = hex_to_bytes(&calldata_hex)?;
let unsigned = rlp_legacy_unsigned(
nonce,
gas_price,
gas_limit,
REGISTRY_ADDRESS,
0,
&calldata_bytes,
CHAIN_ID,
)?;
let mut hasher = Keccak256::new();
hasher.update(&unsigned);
let mut prehash = [0u8; 32];
prehash.copy_from_slice(&hasher.finalize());
let sig = wallet::sign_hash(signer, &prehash);
let r = &sig[..32];
let s = &sig[32..64];
let rec_id = (sig[64] - 27) as u64;
let v = CHAIN_ID * 2 + 35 + rec_id;
let signed = rlp_legacy_signed(
nonce,
gas_price,
gas_limit,
REGISTRY_ADDRESS,
0,
&calldata_bytes,
v,
r,
s,
)?;
let raw_hex = format!("0x{}", bytes_to_hex(&signed));
let tx_hash = eth_send_raw_transaction(&raw_hex).await?;
wait_for_receipt(&tx_hash).await?;
Ok(tx_hash)
}
pub async fn request_faucet_funds(address_hex: &str) -> Result<(), String> {
let body = RpcRequest {
jsonrpc: "2.0",
id: 1,
method: "tempo_fundAddress",
params: serde_json::json!([address_hex]),
};
let client = reqwest::Client::new();
let resp = client
.post(RPC_URL)
.json(&body)
.send()
.await
.map_err(|e| format!("faucet send: {e}"))?;
let parsed: RpcResponse = resp
.json()
.await
.map_err(|e| format!("faucet decode: {e}"))?;
if let Some(err) = parsed.error {
return Err(format!("faucet: {}", err.message));
}
Ok(())
}
fn selector(signature: &str) -> [u8; 4] {
let mut h = Keccak256::new();
h.update(signature.as_bytes());
let digest = h.finalize();
let mut out = [0u8; 4];
out.copy_from_slice(&digest[..4]);
out
}
fn encode_id_of_name(name: &str) -> String {
let sel = selector("idOfName(string)");
let bytes = name.as_bytes();
let len = bytes.len();
let padded_len = len.div_ceil(32) * 32;
let mut buf = Vec::with_capacity(4 + 32 + 32 + padded_len);
buf.extend_from_slice(&sel);
buf.extend_from_slice(&u256_be(0x20));
buf.extend_from_slice(&u256_be(len as u128));
buf.extend_from_slice(bytes);
buf.resize(4 + 32 + 32 + padded_len, 0);
let mut out = String::with_capacity(2 + buf.len() * 2);
out.push_str("0x");
for b in &buf {
out.push_str(&format!("{b:02x}"));
}
out
}
fn encode_register(name: &str) -> String {
let sel = selector("register(string)");
let bytes = name.as_bytes();
let len = bytes.len();
let padded_len = len.div_ceil(32) * 32;
let mut buf = Vec::with_capacity(4 + 32 + 32 + padded_len);
buf.extend_from_slice(&sel);
buf.extend_from_slice(&u256_be(0x20));
buf.extend_from_slice(&u256_be(len as u128));
buf.extend_from_slice(bytes);
buf.resize(4 + 32 + 32 + padded_len, 0);
let mut out = String::with_capacity(2 + buf.len() * 2);
out.push_str("0x");
for b in &buf {
out.push_str(&format!("{b:02x}"));
}
out
}
fn u256_be(value: u128) -> [u8; 32] {
let mut out = [0u8; 32];
out[16..].copy_from_slice(&value.to_be_bytes());
out
}
fn decode_u256_as_u64(hex: &str) -> Result<u64, String> {
let stripped = hex.trim().trim_start_matches("0x");
if stripped.is_empty() {
return Ok(0);
}
if stripped.len() > 64 {
return Err(format!("u256 hex too long: {}", stripped.len()));
}
let high_end = stripped.len().saturating_sub(16);
if stripped[..high_end].chars().any(|c| c != '0') {
return Err("u256 exceeds u64 range".into());
}
let tail = &stripped[high_end..];
u64::from_str_radix(tail, 16).map_err(|e| e.to_string())
}
fn zero_address() -> &'static str {
"0x0000000000000000000000000000000000000000"
}
fn address_to_hex(addr: &[u8; 20]) -> String {
let mut s = String::with_capacity(42);
s.push_str("0x");
for b in addr {
s.push_str(&format!("{b:02x}"));
}
s
}
fn hex_to_bytes(hex: &str) -> Result<Vec<u8>, String> {
let trimmed = hex.trim().trim_start_matches("0x").trim_start_matches("0X");
if trimmed.len() % 2 != 0 {
return Err("hex odd length".into());
}
let mut out = Vec::with_capacity(trimmed.len() / 2);
let bytes = trimmed.as_bytes();
let mut i = 0;
while i < bytes.len() {
let hi = nibble_value(bytes[i])?;
let lo = nibble_value(bytes[i + 1])?;
out.push((hi << 4) | lo);
i += 2;
}
Ok(out)
}
fn nibble_value(b: u8) -> Result<u8, String> {
match b {
b'0'..=b'9' => Ok(b - b'0'),
b'a'..=b'f' => Ok(b - b'a' + 10),
b'A'..=b'F' => Ok(b - b'A' + 10),
_ => Err(format!("non-hex byte {b}")),
}
}
fn bytes_to_hex(bytes: &[u8]) -> String {
let mut s = String::with_capacity(bytes.len() * 2);
for b in bytes {
s.push_str(&format!("{b:02x}"));
}
s
}
fn parse_hex_quantity(hex: &str) -> Result<u128, String> {
let trimmed = hex.trim().trim_start_matches("0x");
if trimmed.is_empty() {
return Ok(0);
}
u128::from_str_radix(trimmed, 16).map_err(|e| e.to_string())
}
pub async fn next_nonce(address_hex: &str) -> Result<u128, String> {
eth_get_transaction_count(address_hex).await
}
pub async fn current_gas_price() -> Result<u128, String> {
eth_gas_price().await
}
pub async fn submit_and_wait_receipt(raw_hex: &str) -> Result<String, String> {
let tx_hash = eth_send_raw_transaction(raw_hex).await?;
wait_for_receipt(&tx_hash).await?;
Ok(tx_hash)
}
pub fn rlp_native_transfer_unsigned(
to_hex: &str,
value_wei: u128,
nonce: u128,
gas_price: u128,
gas_limit: u128,
) -> Result<Vec<u8>, String> {
rlp_legacy_unsigned(nonce, gas_price, gas_limit, to_hex, value_wei, &[], CHAIN_ID)
}
pub fn rlp_native_transfer_signed(
to_hex: &str,
value_wei: u128,
nonce: u128,
gas_price: u128,
gas_limit: u128,
sig_65: &[u8; 65],
) -> Result<String, String> {
let rec_id = (sig_65[64] - 27) as u64;
let v = CHAIN_ID * 2 + 35 + rec_id;
let signed = rlp_legacy_signed(
nonce,
gas_price,
gas_limit,
to_hex,
value_wei,
&[],
v,
&sig_65[..32],
&sig_65[32..64],
)?;
Ok(format!("0x{}", bytes_to_hex(&signed)))
}
pub const NATIVE_TRANSFER_GAS_LIMIT: u128 = 21_000;
pub async fn balance_of(address_hex: &str) -> Result<u128, String> {
let hex = rpc(
"eth_getBalance",
serde_json::json!([address_hex, "latest"]),
)
.await?;
parse_hex_quantity(&hex)
}
pub async fn wait_for_min_balance(
address_hex: &str,
min_wei: u128,
max_attempts: u32,
) -> Result<u128, String> {
for _ in 0..max_attempts {
let bal = balance_of(address_hex).await?;
if bal >= min_wei {
return Ok(bal);
}
sleep_ms(1000).await;
}
Err(format!(
"balance for {address_hex} did not reach {min_wei} wei within {max_attempts}s"
))
}
pub async fn token_balance_of(holder_hex: &str) -> Result<u128, String> {
if LOCALHARNESS_TOKEN_ADDRESS == zero_address() {
return Err("localharness token not deployed".into());
}
let selector = selector("balanceOf(address)");
let holder_bytes = hex_to_bytes(holder_hex)?;
if holder_bytes.len() != 20 {
return Err(format!("holder must be 20 bytes, got {}", holder_bytes.len()));
}
let mut padded = [0u8; 32];
padded[12..].copy_from_slice(&holder_bytes);
let mut calldata = Vec::with_capacity(36);
calldata.extend_from_slice(&selector);
calldata.extend_from_slice(&padded);
let calldata_hex = format!("0x{}", bytes_to_hex(&calldata));
let result = eth_call(LOCALHARNESS_TOKEN_ADDRESS, &calldata_hex).await?;
decode_u256_as_u128(&result)
}
pub async fn erc20_balance_of(token_hex: &str, holder_hex: &str) -> Result<u128, String> {
let holder_bytes = hex_to_bytes(holder_hex)?;
if holder_bytes.len() != 20 {
return Err(format!("holder must be 20 bytes, got {}", holder_bytes.len()));
}
let mut padded = [0u8; 32];
padded[12..].copy_from_slice(&holder_bytes);
let mut calldata = Vec::with_capacity(36);
calldata.extend_from_slice(&selector("balanceOf(address)"));
calldata.extend_from_slice(&padded);
let calldata_hex = format!("0x{}", bytes_to_hex(&calldata));
let result = eth_call(token_hex, &calldata_hex).await?;
decode_u256_as_u128(&result)
}
pub async fn token_transfer(
signer: &SigningKey,
to_hex: &str,
amount_token_wei: u128,
) -> Result<String, String> {
let to_bytes = hex_to_bytes(to_hex)?;
if to_bytes.len() != 20 {
return Err(format!("to must be 20 bytes, got {}", to_bytes.len()));
}
let selector = selector("transfer(address,uint256)");
let mut to_padded = [0u8; 32];
to_padded[12..].copy_from_slice(&to_bytes);
let amount_bytes = u256_be(amount_token_wei);
let mut calldata = Vec::with_capacity(4 + 32 + 32);
calldata.extend_from_slice(&selector);
calldata.extend_from_slice(&to_padded);
calldata.extend_from_slice(&amount_bytes);
sign_and_submit_call(signer, LOCALHARNESS_TOKEN_ADDRESS, 0, &calldata).await
}
async fn sign_and_submit_call(
signer: &SigningKey,
to_hex: &str,
value_wei: u128,
calldata: &[u8],
) -> Result<String, String> {
if to_hex == zero_address() {
return Err("target contract address is zero".into());
}
let from_bytes = wallet::address(signer);
let from_hex = address_to_hex(&from_bytes);
let nonce = eth_get_transaction_count(&from_hex).await?;
let gas_price = eth_gas_price().await?;
let calldata_hex = format!("0x{}", bytes_to_hex(calldata));
let gas_limit = eth_estimate_gas(&from_hex, to_hex, &calldata_hex).await?;
let unsigned = rlp_legacy_unsigned(
nonce, gas_price, gas_limit, to_hex, value_wei, calldata, CHAIN_ID,
)?;
let mut hasher = Keccak256::new();
hasher.update(&unsigned);
let mut prehash = [0u8; 32];
prehash.copy_from_slice(&hasher.finalize());
let sig = wallet::sign_hash(signer, &prehash);
let rec_id = (sig[64] - 27) as u64;
let v = CHAIN_ID * 2 + 35 + rec_id;
let signed = rlp_legacy_signed(
nonce, gas_price, gas_limit, to_hex, value_wei, calldata,
v, &sig[..32], &sig[32..64],
)?;
let raw_hex = format!("0x{}", bytes_to_hex(&signed));
let tx_hash = eth_send_raw_transaction(&raw_hex).await?;
wait_for_receipt(&tx_hash).await?;
Ok(tx_hash)
}
fn decode_u256_as_u128(hex: &str) -> Result<u128, String> {
let trimmed = hex.trim_start_matches("0x");
if trimmed.is_empty() {
return Ok(0);
}
let tail = if trimmed.len() <= 32 {
trimmed
} else {
&trimmed[trimmed.len() - 32..]
};
u128::from_str_radix(tail, 16).map_err(|e| e.to_string())
}
pub const ALPHA_USD_ADDRESS: &str = "0x20c0000000000000000000000000000000000001";
pub async fn submit_tempo_self_paid(
sender: &SigningKey,
calls: Vec<crate::tempo_tx::TempoCall>,
fee_token: Option<&str>,
gas_limit: u128,
) -> Result<String, String> {
use crate::tempo_tx::{sign_self_paid, TempoTxBuilder};
let sender_addr = wallet::address(sender);
let sender_hex = address_to_hex(&sender_addr);
let nonce = eth_get_transaction_count(&sender_hex).await?;
let gas_price = eth_gas_price().await?;
let mut builder = TempoTxBuilder::new(CHAIN_ID)
.max_priority_fee_per_gas(gas_price)
.max_fee_per_gas(gas_price)
.gas_limit(gas_limit)
.nonce(nonce)
.calls(calls);
if let Some(token) = fee_token {
builder = builder.fee_token(parse_eth_address(token)?);
}
let tx = builder.build();
let raw = sign_self_paid(tx, sender);
let raw_hex = format!("0x{}", bytes_to_hex(&raw));
let tx_hash = eth_send_raw_transaction(&raw_hex).await?;
wait_for_receipt(&tx_hash).await?;
Ok(tx_hash)
}
pub async fn submit_tempo_sponsored(
sender: &SigningKey,
fee_payer: &SigningKey,
calls: Vec<crate::tempo_tx::TempoCall>,
fee_token: &str,
gas_limit: u128,
) -> Result<String, String> {
use crate::tempo_tx::{sign_sponsored, TempoTxBuilder};
let sender_addr = wallet::address(sender);
let sender_hex = address_to_hex(&sender_addr);
let nonce = eth_get_transaction_count(&sender_hex).await?;
let gas_price = eth_gas_price().await?;
let tx = TempoTxBuilder::new(CHAIN_ID)
.max_priority_fee_per_gas(gas_price)
.max_fee_per_gas(gas_price)
.gas_limit(gas_limit)
.nonce(nonce)
.calls(calls)
.fee_token(parse_eth_address(fee_token)?)
.sponsored()
.build();
let raw = sign_sponsored(tx, sender, fee_payer);
let raw_hex = format!("0x{}", bytes_to_hex(&raw));
let tx_hash = eth_send_raw_transaction(&raw_hex).await?;
wait_for_receipt(&tx_hash).await?;
Ok(tx_hash)
}
fn parse_eth_address(hex_str: &str) -> Result<[u8; 20], String> {
let bytes = hex_to_bytes(hex_str)?;
if bytes.len() != 20 {
return Err(format!("address must be 20 bytes, got {}", bytes.len()));
}
let mut out = [0u8; 20];
out.copy_from_slice(&bytes);
Ok(out)
}
pub async fn main_of(holder_hex: &str) -> Result<u64, String> {
if REGISTRY_ADDRESS == zero_address() {
return Ok(0);
}
let selector = selector("mainOf(address)");
let holder_bytes = hex_to_bytes(holder_hex)?;
if holder_bytes.len() != 20 {
return Err(format!("holder must be 20 bytes, got {}", holder_bytes.len()));
}
let mut padded = [0u8; 32];
padded[12..].copy_from_slice(&holder_bytes);
let mut calldata = Vec::with_capacity(36);
calldata.extend_from_slice(&selector);
calldata.extend_from_slice(&padded);
let calldata_hex = format!("0x{}", bytes_to_hex(&calldata));
let result = eth_call(REGISTRY_ADDRESS, &calldata_hex).await?;
decode_u256_as_u64(&result)
}
pub async fn register_main(signer: &SigningKey, token_id: u64) -> Result<String, String> {
sign_and_submit_call(signer, REGISTRY_ADDRESS, 0, &encode_register_main(token_id)).await
}
pub async fn register_main_sponsored(
sender: &SigningKey,
fee_payer: &SigningKey,
token_id: u64,
fee_token: &str,
) -> Result<String, String> {
let diamond_addr = parse_eth_address(REGISTRY_ADDRESS)?;
let token_addr = parse_eth_address(LOCALHARNESS_TOKEN_ADDRESS)?;
let cost = main_cost().await.unwrap_or(0);
let main_call = crate::tempo_tx::TempoCall {
to: diamond_addr,
value_wei: 0,
input: encode_register_main(token_id),
};
let calls = if cost > 0 {
let approve_call = crate::tempo_tx::TempoCall {
to: token_addr,
value_wei: 0,
input: encode_approve(&diamond_addr, cost),
};
vec![approve_call, main_call]
} else {
vec![main_call]
};
submit_tempo_sponsored(sender, fee_payer, calls, fee_token, 700_000).await
}
fn encode_register_main(token_id: u64) -> Vec<u8> {
let mut data = Vec::with_capacity(4 + 32);
data.extend_from_slice(&selector("registerMain(uint256)"));
data.extend_from_slice(&u256_be(token_id as u128));
data
}
pub async fn is_authorized_signer(tba_address: &str, signer_hex: &str) -> Result<bool, String> {
let mut data = Vec::with_capacity(4 + 32);
data.extend_from_slice(&selector("isAuthorizedSigner(address)"));
let signer_bytes = hex_to_bytes(signer_hex)?;
if signer_bytes.len() != 20 {
return Err(format!("signer must be 20 bytes, got {}", signer_bytes.len()));
}
let mut padded = [0u8; 32];
padded[12..].copy_from_slice(&signer_bytes);
data.extend_from_slice(&padded);
let calldata = format!("0x{}", bytes_to_hex(&data));
let result_hex = eth_call(tba_address, &calldata).await?;
let trimmed = result_hex.trim().trim_start_matches("0x");
Ok(trimmed.chars().last().map(|c| c == '1').unwrap_or(false))
}
pub async fn tba_token_id_of(tba_hex: &str) -> Result<u64, String> {
let calldata = format!("0x{}", bytes_to_hex(&selector("token()")));
let result = eth_call(tba_hex, &calldata).await?;
let bytes = hex_to_bytes(&result)?;
if bytes.len() < 96 {
return Err("token(): short response".into());
}
let mut buf = [0u8; 8];
buf.copy_from_slice(&bytes[88..96]); Ok(u64::from_be_bytes(buf))
}
pub async fn tba_execute_batch_sponsored(
signer: &SigningKey,
fee_payer: &SigningKey,
token_id: u64,
tba_hex: &str,
targets: &[([u8; 20], Vec<u8>)],
fee_token: &str,
gas_limit: u128,
) -> Result<String, String> {
let diamond = parse_eth_address(REGISTRY_ADDRESS)?;
let tba = parse_eth_address(tba_hex)?;
let mut calls = Vec::with_capacity(targets.len() + 1);
calls.push(crate::tempo_tx::TempoCall {
to: diamond,
value_wei: 0,
input: encode_create_tba(token_id),
});
for (target, data) in targets {
calls.push(crate::tempo_tx::TempoCall {
to: tba,
value_wei: 0,
input: encode_tba_execute(target, 0, data),
});
}
submit_tempo_sponsored(signer, fee_payer, calls, fee_token, gas_limit).await
}
pub async fn add_signer_sponsored(
sender: &SigningKey,
fee_payer: &SigningKey,
token_id: u64,
tba_address: &str,
new_signer_hex: &str,
fee_token: &str,
) -> Result<String, String> {
let new_signer = parse_eth_address(new_signer_hex)?;
let tba_addr = parse_eth_address(tba_address)?;
let diamond_addr = parse_eth_address(REGISTRY_ADDRESS)?;
let create_call = crate::tempo_tx::TempoCall {
to: diamond_addr,
value_wei: 0,
input: encode_create_tba(token_id),
};
let add_call = crate::tempo_tx::TempoCall {
to: tba_addr,
value_wei: 0,
input: encode_add_signer(&new_signer),
};
let link_call = crate::tempo_tx::TempoCall {
to: diamond_addr,
value_wei: 0,
input: encode_link_device(token_id, &new_signer),
};
submit_tempo_sponsored(
sender,
fee_payer,
vec![create_call, add_call, link_call],
fee_token,
2_200_000,
)
.await
}
fn encode_link_device(main_id: u64, device: &[u8; 20]) -> Vec<u8> {
let mut out = Vec::with_capacity(4 + 64);
out.extend_from_slice(&selector("linkDevice(uint256,address)"));
out.extend_from_slice(&u256_be(main_id as u128));
let mut padded = [0u8; 32];
padded[12..].copy_from_slice(device);
out.extend_from_slice(&padded);
out
}
pub async fn devices_of(main_id: u64) -> Result<Vec<String>, String> {
if REGISTRY_ADDRESS == zero_address() {
return Ok(Vec::new());
}
let mut calldata = selector("devicesOf(uint256)").to_vec();
calldata.extend_from_slice(&u256_be(main_id as u128));
let calldata_hex = format!("0x{}", bytes_to_hex(&calldata));
let result = eth_call(REGISTRY_ADDRESS, &calldata_hex).await?;
let bytes = hex_to_bytes(&result)?;
if bytes.len() < 64 {
return Ok(Vec::new());
}
let mut len_buf = [0u8; 8];
len_buf.copy_from_slice(&bytes[56..64]); let len = u64::from_be_bytes(len_buf) as usize;
let mut out = Vec::new();
for i in 0..len {
let start = match i.checked_mul(32).and_then(|o| o.checked_add(64)) {
Some(s) => s,
None => break,
};
let Some(word) = start
.checked_add(32)
.and_then(|end| bytes.get(start + 12..end))
else {
break;
};
out.push(format!("0x{}", bytes_to_hex(word)));
}
Ok(out)
}
pub async fn is_device_linked(main_id: u64, addr_hex: &str) -> Result<bool, String> {
if REGISTRY_ADDRESS == zero_address() {
return Ok(false);
}
let addr = parse_eth_address(addr_hex)?;
let mut calldata = selector("isDeviceLinked(uint256,address)").to_vec();
calldata.extend_from_slice(&u256_be(main_id as u128));
calldata.extend_from_slice(&addr_word(&addr));
let calldata_hex = format!("0x{}", bytes_to_hex(&calldata));
let result = eth_call(REGISTRY_ADDRESS, &calldata_hex).await?;
decode_u256_as_u64(&result).map(|v| v != 0)
}
fn encode_unlink_device(main_id: u64, device: &[u8; 20]) -> Vec<u8> {
let mut out = Vec::with_capacity(4 + 64);
out.extend_from_slice(&selector("unlinkDevice(uint256,address)"));
out.extend_from_slice(&u256_be(main_id as u128));
let mut padded = [0u8; 32];
padded[12..].copy_from_slice(device);
out.extend_from_slice(&padded);
out
}
fn encode_erc721_transfer_from(from: &[u8; 20], to: &[u8; 20], token_id: u64) -> Vec<u8> {
let mut out = Vec::with_capacity(4 + 96);
out.extend_from_slice(&selector("transferFrom(address,address,uint256)"));
out.extend_from_slice(&addr_word(from));
out.extend_from_slice(&addr_word(to));
out.extend_from_slice(&u256_be(token_id as u128));
out
}
pub async fn consolidate_into_main_sponsored(
owner: &SigningKey,
fee_payer: &SigningKey,
main_tba_hex: &str,
token_ids: &[u64],
fee_token: &str,
) -> Result<String, String> {
if token_ids.is_empty() {
return Err("no subdomains to consolidate".into());
}
let diamond_addr = parse_eth_address(REGISTRY_ADDRESS)?;
let to = parse_eth_address(main_tba_hex)?;
let from = wallet::address(owner);
let calls: Vec<_> = token_ids
.iter()
.map(|&tid| crate::tempo_tx::TempoCall {
to: diamond_addr,
value_wei: 0,
input: encode_erc721_transfer_from(&from, &to, tid),
})
.collect();
let gas = 300_000 + token_ids.len() as u128 * 90_000;
submit_tempo_sponsored(owner, fee_payer, calls, fee_token, gas).await
}
fn encode_release_name(token_id: u64) -> Vec<u8> {
let mut out = Vec::with_capacity(4 + 32);
out.extend_from_slice(&selector("releaseName(uint256)"));
out.extend_from_slice(&u256_be(token_id as u128));
out
}
pub fn release_name_calldata(token_id: u64) -> Vec<u8> {
encode_release_name(token_id)
}
pub fn register_calldata(name: &str) -> Vec<u8> {
hex_to_bytes(&encode_register(name)).unwrap_or_default()
}
pub async fn release_name_sponsored(
sender: &SigningKey,
fee_payer: &SigningKey,
token_id: u64,
fee_token: &str,
) -> Result<String, String> {
let diamond_addr = parse_eth_address(REGISTRY_ADDRESS)?;
let call = crate::tempo_tx::TempoCall {
to: diamond_addr,
value_wei: 0,
input: encode_release_name(token_id),
};
submit_tempo_sponsored(sender, fee_payer, vec![call], fee_token, 1_000_000).await
}
pub async fn release_names_sponsored(
sender: &SigningKey,
fee_payer: &SigningKey,
token_ids: &[u64],
fee_token: &str,
) -> Result<String, String> {
if token_ids.is_empty() {
return Err("no subdomains to release".into());
}
let diamond_addr = parse_eth_address(REGISTRY_ADDRESS)?;
let calls: Vec<_> = token_ids
.iter()
.map(|&tid| crate::tempo_tx::TempoCall {
to: diamond_addr,
value_wei: 0,
input: encode_release_name(tid),
})
.collect();
let gas = 1_000_000 + (token_ids.len() as u128).saturating_sub(1) * 250_000;
submit_tempo_sponsored(sender, fee_payer, calls, fee_token, gas).await
}
pub async fn remove_signer_sponsored(
sender: &SigningKey,
fee_payer: &SigningKey,
token_id: u64,
tba_address: &str,
signer_hex: &str,
fee_token: &str,
) -> Result<String, String> {
let signer_addr = parse_eth_address(signer_hex)?;
let tba_addr = parse_eth_address(tba_address)?;
let diamond_addr = parse_eth_address(REGISTRY_ADDRESS)?;
let remove_call = crate::tempo_tx::TempoCall {
to: tba_addr,
value_wei: 0,
input: encode_remove_signer(&signer_addr),
};
let unlink_call = crate::tempo_tx::TempoCall {
to: diamond_addr,
value_wei: 0,
input: encode_unlink_device(token_id, &signer_addr),
};
submit_tempo_sponsored(sender, fee_payer, vec![remove_call, unlink_call], fee_token, 600_000)
.await
}
pub fn pairing_code_hash(code: &str) -> [u8; 32] {
use sha3::{Digest, Keccak256};
let mut out = [0u8; 32];
out.copy_from_slice(&Keccak256::digest(code.trim().as_bytes()));
out
}
pub async fn announce_pairing_sponsored(
device: &SigningKey,
fee_payer: &SigningKey,
code_hash: &[u8; 32],
pubkey: &[u8],
fee_token: &str,
) -> Result<String, String> {
let padded = pubkey.len().div_ceil(32) * 32;
let mut input = Vec::with_capacity(4 + 32 + 32 + 32 + padded);
input.extend_from_slice(&selector("announcePairing(bytes32,bytes)"));
input.extend_from_slice(code_hash);
input.extend_from_slice(&u256_be(0x40));
input.extend_from_slice(&u256_be(pubkey.len() as u128));
input.extend_from_slice(pubkey);
input.resize(4 + 32 + 32 + 32 + padded, 0);
let call = crate::tempo_tx::TempoCall {
to: parse_eth_address(REGISTRY_ADDRESS)?,
value_wei: 0,
input,
};
submit_tempo_sponsored(device, fee_payer, vec![call], fee_token, 450_000).await
}
pub async fn find_pairing_device(
code_hash: &[u8; 32],
) -> Result<Option<(String, Vec<u8>)>, String> {
if REGISTRY_ADDRESS == zero_address() {
return Ok(None);
}
use sha3::{Digest, Keccak256};
let topic0 = format!(
"0x{}",
bytes_to_hex(&Keccak256::digest(
b"PairingAnnounced(bytes32,address,bytes,uint256)"
))
);
let code_topic = format!("0x{}", bytes_to_hex(code_hash));
let latest_hex = rpc("eth_blockNumber", serde_json::json!([])).await?;
let latest = parse_hex_quantity(&latest_hex)? as u64;
let from = latest.saturating_sub(99_000);
let from_hex = format!("0x{from:x}");
let logs = eth_get_logs(
REGISTRY_ADDRESS,
vec![serde_json::json!(topic0), serde_json::json!(code_topic)],
&from_hex,
)
.await?;
for log in &logs {
let topics = log.get("topics").and_then(|t| t.as_array());
let device = topics
.and_then(|t| t.get(2))
.and_then(|t| t.as_str())
.map(|s| s.trim_start_matches("0x"))
.filter(|s| s.len() >= 64)
.map(|s| format!("0x{}", &s[24..]).to_lowercase());
let Some(device) = device else { continue };
let data_hex = log.get("data").and_then(|d| d.as_str()).unwrap_or("0x");
let data = hex_to_bytes(data_hex).unwrap_or_default();
let pubkey = if data.len() >= 96 {
let mut len_buf = [0u8; 8];
len_buf.copy_from_slice(&data[88..96]);
let len = u64::from_be_bytes(len_buf) as usize;
len.checked_add(96)
.and_then(|end| data.get(96..end))
.map(|s| s.to_vec())
.unwrap_or_default()
} else {
Vec::new()
};
return Ok(Some((device, pubkey)));
}
Ok(None)
}
fn gemini_key_dev_metadata_key(device_addr: &[u8; 20]) -> [u8; 32] {
use sha3::{Digest, Keccak256};
let mut hasher = Keccak256::new();
hasher.update(b"localharness.gemini_key.dev.");
hasher.update(device_addr);
let mut out = [0u8; 32];
out.copy_from_slice(&hasher.finalize());
out
}
pub async fn wrapped_device_key_of(
token_id: u64,
device_addr_hex: &str,
) -> Result<Option<Vec<u8>>, String> {
let device_addr = parse_eth_address(device_addr_hex)?;
let mut data = Vec::with_capacity(4 + 64);
data.extend_from_slice(&selector("metadata(uint256,bytes32)"));
data.extend_from_slice(&u256_be(token_id as u128));
data.extend_from_slice(&gemini_key_dev_metadata_key(&device_addr));
let calldata = format!("0x{}", bytes_to_hex(&data));
let result_hex = eth_call(REGISTRY_ADDRESS, &calldata).await?;
let bytes = hex_to_bytes(&result_hex)?;
if bytes.len() < 64 {
return Ok(None);
}
let mut len_buf = [0u8; 8];
len_buf.copy_from_slice(&bytes[56..64]);
let len = u64::from_be_bytes(len_buf) as usize;
if len == 0 {
return Ok(None);
}
let payload = len
.checked_add(64)
.and_then(|end| bytes.get(64..end))
.ok_or_else(|| "wrapped key truncated".to_string())?;
Ok(Some(payload.to_vec()))
}
pub async fn set_device_wrapped_key_sponsored(
sender: &SigningKey,
fee_payer: &SigningKey,
token_id: u64,
device_addr_hex: &str,
blob: &[u8],
fee_token: &str,
) -> Result<String, String> {
let device_addr = parse_eth_address(device_addr_hex)?;
let key = gemini_key_dev_metadata_key(&device_addr);
let len = blob.len();
let padded = len.div_ceil(32) * 32;
let mut input = Vec::with_capacity(4 + 96 + 32 + padded);
input.extend_from_slice(&selector("setMetadata(uint256,bytes32,bytes)"));
input.extend_from_slice(&u256_be(token_id as u128));
input.extend_from_slice(&key);
input.extend_from_slice(&u256_be(0x60));
input.extend_from_slice(&u256_be(len as u128));
input.extend_from_slice(blob);
input.resize(4 + 96 + 32 + padded, 0);
let call = crate::tempo_tx::TempoCall {
to: parse_eth_address(REGISTRY_ADDRESS)?,
value_wei: 0,
input,
};
let words = (padded / 32 + 4) as u128;
submit_tempo_sponsored(sender, fee_payer, vec![call], fee_token, 1_200_000 + words * 40_000).await
}
pub async fn main_cost() -> Result<u128, String> {
if REGISTRY_ADDRESS == zero_address() {
return Ok(0);
}
let calldata = format!("0x{}", bytes_to_hex(&selector("mainCost()")));
let result = eth_call(REGISTRY_ADDRESS, &calldata).await?;
decode_u256_as_u128(&result)
}
pub async fn treasury_balance() -> Result<u128, String> {
if REGISTRY_ADDRESS == zero_address() {
return Ok(0);
}
let calldata = format!("0x{}", bytes_to_hex(&selector("treasuryBalance()")));
let result = eth_call(REGISTRY_ADDRESS, &calldata).await?;
decode_u256_as_u128(&result)
}
pub async fn registration_cost() -> Result<u128, String> {
if REGISTRY_ADDRESS == zero_address() {
return Ok(0);
}
let calldata = format!("0x{}", bytes_to_hex(&selector("registrationCost()")));
let result = eth_call(REGISTRY_ADDRESS, &calldata).await?;
decode_u256_as_u128(&result)
}
fn encode_approve(spender: &[u8; 20], amount_wei: u128) -> Vec<u8> {
let sel = selector("approve(address,uint256)");
let mut spender_padded = [0u8; 32];
spender_padded[12..].copy_from_slice(spender);
let amount_padded = u256_be(amount_wei);
let mut out = Vec::with_capacity(4 + 32 + 32);
out.extend_from_slice(&sel);
out.extend_from_slice(&spender_padded);
out.extend_from_slice(&amount_padded);
out
}
fn encode_transfer(to: &[u8; 20], amount_wei: u128) -> Vec<u8> {
let sel = selector("transfer(address,uint256)");
let mut to_padded = [0u8; 32];
to_padded[12..].copy_from_slice(to);
let amount_padded = u256_be(amount_wei);
let mut out = Vec::with_capacity(4 + 32 + 32);
out.extend_from_slice(&sel);
out.extend_from_slice(&to_padded);
out.extend_from_slice(&amount_padded);
out
}
pub async fn claim_daily_sponsored(
sender: &SigningKey,
fee_payer: &SigningKey,
fee_token: &str,
) -> Result<String, String> {
let call = crate::tempo_tx::TempoCall {
to: parse_eth_address(REGISTRY_ADDRESS)?,
value_wei: 0,
input: selector("claimDaily()").to_vec(),
};
submit_tempo_sponsored(sender, fee_payer, vec![call], fee_token, 600_000).await
}
pub async fn can_claim_credits(account_hex: &str) -> Result<bool, String> {
if REGISTRY_ADDRESS == zero_address() {
return Ok(false);
}
let mut data = Vec::with_capacity(4 + 32);
data.extend_from_slice(&selector("canClaim(address)"));
let account_bytes = hex_to_bytes(account_hex)?;
if account_bytes.len() != 20 {
return Err(format!("account must be 20 bytes, got {}", account_bytes.len()));
}
let mut padded = [0u8; 32];
padded[12..].copy_from_slice(&account_bytes);
data.extend_from_slice(&padded);
let calldata = format!("0x{}", bytes_to_hex(&data));
let result_hex = eth_call(REGISTRY_ADDRESS, &calldata).await?;
let trimmed = result_hex.trim().trim_start_matches("0x");
Ok(trimmed.chars().last().map(|c| c == '1').unwrap_or(false))
}
pub async fn daily_allowance() -> Result<u128, String> {
if REGISTRY_ADDRESS == zero_address() {
return Ok(0);
}
let calldata = format!("0x{}", bytes_to_hex(&selector("dailyAllowance()")));
let result = eth_call(REGISTRY_ADDRESS, &calldata).await?;
decode_u256_as_u128(&result)
}
pub async fn last_claim_day(account_hex: &str) -> Result<u64, String> {
if REGISTRY_ADDRESS == zero_address() {
return Ok(0);
}
let mut data = Vec::with_capacity(4 + 32);
data.extend_from_slice(&selector("lastClaimDay(address)"));
let account_bytes = hex_to_bytes(account_hex)?;
if account_bytes.len() != 20 {
return Err(format!("account must be 20 bytes, got {}", account_bytes.len()));
}
let mut padded = [0u8; 32];
padded[12..].copy_from_slice(&account_bytes);
data.extend_from_slice(&padded);
let calldata = format!("0x{}", bytes_to_hex(&data));
let result_hex = eth_call(REGISTRY_ADDRESS, &calldata).await?;
let val = decode_u256_as_u128(&result_hex)?;
Ok(val as u64)
}
#[allow(clippy::too_many_arguments)]
pub async fn tba_execute_sponsored(
sender: &SigningKey,
fee_payer: &SigningKey,
token_id: u64,
tba_address: &str,
target_hex: &str,
value_wei: u128,
inner_data: Vec<u8>,
fee_token: &str,
gas_limit: u128,
) -> Result<String, String> {
let tba_addr = parse_eth_address(tba_address)?;
let diamond_addr = parse_eth_address(REGISTRY_ADDRESS)?;
let target = parse_eth_address(target_hex)?;
let create_call = crate::tempo_tx::TempoCall {
to: diamond_addr,
value_wei: 0,
input: encode_create_tba(token_id),
};
let execute_call = crate::tempo_tx::TempoCall {
to: tba_addr,
value_wei: 0,
input: encode_tba_execute(&target, value_wei, &inner_data),
};
submit_tempo_sponsored(
sender,
fee_payer,
vec![create_call, execute_call],
fee_token,
gas_limit,
)
.await
}
pub async fn tba_transfer_lh_sponsored(
sender: &SigningKey,
fee_payer: &SigningKey,
token_id: u64,
tba_address: &str,
recipient_hex: &str,
amount_wei: u128,
fee_token: &str,
) -> Result<String, String> {
let recipient = parse_eth_address(recipient_hex)?;
let mut transfer_data = Vec::with_capacity(4 + 32 + 32);
transfer_data.extend_from_slice(&selector("transfer(address,uint256)"));
let mut padded = [0u8; 32];
padded[12..].copy_from_slice(&recipient);
transfer_data.extend_from_slice(&padded);
transfer_data.extend_from_slice(&u256_be(amount_wei));
tba_execute_sponsored(
sender,
fee_payer,
token_id,
tba_address,
LOCALHARNESS_TOKEN_ADDRESS,
0,
transfer_data,
fee_token,
2_000_000,
)
.await
}
fn encode_tba_execute(target: &[u8; 20], value_wei: u128, data: &[u8]) -> Vec<u8> {
let sel = selector("execute(address,uint256,bytes,uint8)");
let mut target_padded = [0u8; 32];
target_padded[12..].copy_from_slice(target);
let data_len = data.len();
let padded_len = data_len.div_ceil(32) * 32;
let data_offset: u128 = 0x80;
let mut out = Vec::with_capacity(4 + 128 + 32 + padded_len);
out.extend_from_slice(&sel);
out.extend_from_slice(&target_padded);
out.extend_from_slice(&u256_be(value_wei));
out.extend_from_slice(&u256_be(data_offset));
out.extend_from_slice(&u256_be(0)); out.extend_from_slice(&u256_be(data_len as u128));
out.extend_from_slice(data);
out.resize(out.len() + (padded_len - data_len), 0);
out
}
fn encode_create_tba(token_id: u64) -> Vec<u8> {
let mut data = Vec::with_capacity(4 + 32);
data.extend_from_slice(&selector("createTokenBoundAccount(uint256)"));
data.extend_from_slice(&u256_be(token_id as u128));
data
}
fn encode_add_signer(addr: &[u8; 20]) -> Vec<u8> {
let sel = selector("addSigner(address)");
let mut padded = [0u8; 32];
padded[12..].copy_from_slice(addr);
let mut out = Vec::with_capacity(4 + 32);
out.extend_from_slice(&sel);
out.extend_from_slice(&padded);
out
}
fn encode_remove_signer(addr: &[u8; 20]) -> Vec<u8> {
let sel = selector("removeSigner(address)");
let mut padded = [0u8; 32];
padded[12..].copy_from_slice(addr);
let mut out = Vec::with_capacity(4 + 32);
out.extend_from_slice(&sel);
out.extend_from_slice(&padded);
out
}
pub async fn claim_and_maybe_set_main(
signer: &SigningKey,
name: &str,
) -> Result<String, String> {
let tx_hash = claim_name(signer, name).await?;
let addr_hex = address_to_hex(&wallet::address(signer));
match main_of(&addr_hex).await {
Ok(0) => {
if let Ok(Status::Taken { agent_id }) = check_name(name).await {
if let Err(err) = register_main(signer, agent_id).await {
log_main_warning(&err);
}
}
}
Ok(_) => {} Err(err) => log_main_warning(&err),
}
Ok(tx_hash)
}
pub async fn claim_and_maybe_set_main_sponsored(
sender: &SigningKey,
fee_payer: &SigningKey,
name: &str,
fee_token: &str,
) -> Result<String, String> {
let diamond_addr = parse_eth_address(REGISTRY_ADDRESS)?;
let token_addr = parse_eth_address(LOCALHARNESS_TOKEN_ADDRESS)?;
let cost = registration_cost().await.unwrap_or(0);
let register_input = hex_to_bytes(&encode_register(name))?;
let register_call = crate::tempo_tx::TempoCall {
to: diamond_addr,
value_wei: 0,
input: register_input,
};
let calls = if cost > 0 {
let approve_call = crate::tempo_tx::TempoCall {
to: token_addr,
value_wei: 0,
input: encode_approve(&diamond_addr, cost),
};
vec![approve_call, register_call]
} else {
vec![register_call]
};
let tx_hash = submit_tempo_sponsored(
sender,
fee_payer,
calls,
fee_token,
2_200_000,
)
.await?;
let sender_addr = address_to_hex(&wallet::address(sender));
if let Ok(0) = main_of(&sender_addr).await {
if let Ok(Status::Taken { agent_id }) = check_name(name).await {
if let Err(err) =
register_main_sponsored(sender, fee_payer, agent_id, fee_token).await
{
log_main_warning(&err);
}
}
}
Ok(tx_hash)
}
fn encode_redeem(code: &str) -> Vec<u8> {
let sel = selector("redeem(string)");
let bytes = code.as_bytes();
let len = bytes.len();
let padded_len = len.div_ceil(32) * 32;
let mut buf = Vec::with_capacity(4 + 32 + 32 + padded_len);
buf.extend_from_slice(&sel);
buf.extend_from_slice(&u256_be(0x20));
buf.extend_from_slice(&u256_be(len as u128));
buf.extend_from_slice(bytes);
buf.resize(4 + 32 + 32 + padded_len, 0);
buf
}
pub async fn redeem_sponsored(
sender: &SigningKey,
fee_payer: &SigningKey,
code: &str,
fee_token: &str,
) -> Result<String, String> {
let diamond_addr = parse_eth_address(REGISTRY_ADDRESS)?;
let call = crate::tempo_tx::TempoCall {
to: diamond_addr,
value_wei: 0,
input: encode_redeem(code),
};
submit_tempo_sponsored(sender, fee_payer, vec![call], fee_token, 2_000_000).await
}
pub async fn session_expiry_of(account_hex: &str) -> Result<u64, String> {
if REGISTRY_ADDRESS == zero_address() {
return Ok(0);
}
let account = parse_eth_address(account_hex)?;
let mut padded = [0u8; 32];
padded[12..].copy_from_slice(&account);
let mut calldata = Vec::with_capacity(4 + 32);
calldata.extend_from_slice(&selector("sessionExpiryOf(address)"));
calldata.extend_from_slice(&padded);
let calldata_hex = format!("0x{}", bytes_to_hex(&calldata));
let result = eth_call(REGISTRY_ADDRESS, &calldata_hex).await?;
decode_u256_as_u64(&result)
}
pub async fn session_price() -> Result<u128, String> {
if REGISTRY_ADDRESS == zero_address() {
return Ok(0);
}
let calldata = format!("0x{}", bytes_to_hex(&selector("sessionPrice()")));
let result = eth_call(REGISTRY_ADDRESS, &calldata).await?;
decode_u256_as_u128(&result)
}
pub async fn open_session_sponsored(
sender: &SigningKey,
fee_payer: &SigningKey,
fee_token: &str,
) -> Result<String, String> {
let diamond_addr = parse_eth_address(REGISTRY_ADDRESS)?;
let token_addr = parse_eth_address(LOCALHARNESS_TOKEN_ADDRESS)?;
let price = session_price().await.unwrap_or(0);
let open_call = crate::tempo_tx::TempoCall {
to: diamond_addr,
value_wei: 0,
input: selector("openSession()").to_vec(),
};
let calls = if price > 0 {
let approve_call = crate::tempo_tx::TempoCall {
to: token_addr,
value_wei: 0,
input: encode_approve(&diamond_addr, price),
};
vec![approve_call, open_call]
} else {
vec![open_call]
};
submit_tempo_sponsored(sender, fee_payer, calls, fee_token, 600_000).await
}
fn encode_deposit_credits(amount_wei: u128) -> Vec<u8> {
let mut out = Vec::with_capacity(4 + 32);
out.extend_from_slice(&selector("depositCredits(uint256)"));
out.extend_from_slice(&u256_be(amount_wei));
out
}
pub async fn credit_balance_of(account_hex: &str) -> Result<u128, String> {
if REGISTRY_ADDRESS == zero_address() {
return Ok(0);
}
let account = parse_eth_address(account_hex)?;
let mut padded = [0u8; 32];
padded[12..].copy_from_slice(&account);
let mut calldata = Vec::with_capacity(4 + 32);
calldata.extend_from_slice(&selector("creditOf(address)"));
calldata.extend_from_slice(&padded);
let calldata_hex = format!("0x{}", bytes_to_hex(&calldata));
let result = eth_call(REGISTRY_ADDRESS, &calldata_hex).await?;
decode_u256_as_u128(&result)
}
pub async fn deposit_credits_sponsored(
sender: &SigningKey,
fee_payer: &SigningKey,
amount_wei: u128,
fee_token: &str,
) -> Result<String, String> {
let diamond_addr = parse_eth_address(REGISTRY_ADDRESS)?;
let token_addr = parse_eth_address(LOCALHARNESS_TOKEN_ADDRESS)?;
let approve_call = crate::tempo_tx::TempoCall {
to: token_addr,
value_wei: 0,
input: encode_approve(&diamond_addr, amount_wei),
};
let deposit_call = crate::tempo_tx::TempoCall {
to: diamond_addr,
value_wei: 0,
input: encode_deposit_credits(amount_wei),
};
submit_tempo_sponsored(sender, fee_payer, vec![approve_call, deposit_call], fee_token, 1_500_000)
.await
}
fn keccak32(data: &[u8]) -> [u8; 32] {
let mut h = Keccak256::new();
h.update(data);
let d = h.finalize();
let mut o = [0u8; 32];
o.copy_from_slice(&d);
o
}
fn addr_word(a: &[u8; 20]) -> [u8; 32] {
let mut w = [0u8; 32];
w[12..].copy_from_slice(a);
w
}
pub fn x402_domain_separator() -> Result<[u8; 32], String> {
let diamond = parse_eth_address(REGISTRY_ADDRESS)?;
let mut dom = Vec::with_capacity(160);
dom.extend_from_slice(&keccak32(
b"EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)",
));
dom.extend_from_slice(&keccak32(b"localharness-x402"));
dom.extend_from_slice(&keccak32(b"1"));
dom.extend_from_slice(&u256_be(CHAIN_ID as u128));
dom.extend_from_slice(&addr_word(&diamond));
Ok(keccak32(&dom))
}
pub fn x402_digest(
from: &[u8; 20],
to: &[u8; 20],
value_wei: u128,
valid_after: u64,
valid_before: u64,
nonce: &[u8; 32],
) -> Result<[u8; 32], String> {
let mut st = Vec::with_capacity(224);
st.extend_from_slice(&keccak32(
b"PaymentAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)",
));
st.extend_from_slice(&addr_word(from));
st.extend_from_slice(&addr_word(to));
st.extend_from_slice(&u256_be(value_wei));
st.extend_from_slice(&u256_be(valid_after as u128));
st.extend_from_slice(&u256_be(valid_before as u128));
st.extend_from_slice(nonce);
let struct_hash = keccak32(&st);
let mut pre = Vec::with_capacity(66);
pre.extend_from_slice(&[0x19, 0x01]);
pre.extend_from_slice(&x402_domain_separator()?);
pre.extend_from_slice(&struct_hash);
Ok(keccak32(&pre))
}
pub fn sign_x402(
signer: &SigningKey,
from: &[u8; 20],
to: &[u8; 20],
value_wei: u128,
valid_after: u64,
valid_before: u64,
nonce: &[u8; 32],
) -> Result<[u8; 65], String> {
let digest = x402_digest(from, to, value_wei, valid_after, valid_before, nonce)?;
Ok(crate::wallet::sign_hash(signer, &digest))
}
fn encode_settle(
from: &[u8; 20],
to: &[u8; 20],
value_wei: u128,
valid_after: u64,
valid_before: u64,
nonce: &[u8; 32],
signature: &[u8; 65],
) -> Vec<u8> {
let mut out = Vec::with_capacity(4 + 32 * 9 + 96);
out.extend_from_slice(&selector(
"settle(address,address,uint256,uint256,uint256,bytes32,bytes)",
));
out.extend_from_slice(&addr_word(from));
out.extend_from_slice(&addr_word(to));
out.extend_from_slice(&u256_be(value_wei));
out.extend_from_slice(&u256_be(valid_after as u128));
out.extend_from_slice(&u256_be(valid_before as u128));
out.extend_from_slice(nonce);
out.extend_from_slice(&u256_be(7 * 32)); out.extend_from_slice(&u256_be(signature.len() as u128)); out.extend_from_slice(signature);
out.resize(out.len() + 31, 0); out
}
#[allow(clippy::too_many_arguments)]
pub async fn settle_x402_sponsored(
submitter: &SigningKey,
fee_payer: &SigningKey,
from: &[u8; 20],
to: &[u8; 20],
value_wei: u128,
valid_after: u64,
valid_before: u64,
nonce: &[u8; 32],
signature: &[u8; 65],
fee_token: &str,
) -> Result<String, String> {
let diamond_addr = parse_eth_address(REGISTRY_ADDRESS)?;
let call = crate::tempo_tx::TempoCall {
to: diamond_addr,
value_wei: 0,
input: encode_settle(from, to, value_wei, valid_after, valid_before, nonce, signature),
};
submit_tempo_sponsored(submitter, fee_payer, vec![call], fee_token, 400_000).await
}
pub async fn x402_authorization_state(
from_hex: &str,
nonce: &[u8; 32],
) -> Result<bool, String> {
if REGISTRY_ADDRESS == zero_address() {
return Ok(false);
}
let from = parse_eth_address(from_hex)?;
let mut calldata = Vec::with_capacity(4 + 64);
calldata.extend_from_slice(&selector("authorizationState(address,bytes32)"));
calldata.extend_from_slice(&addr_word(&from));
calldata.extend_from_slice(nonce);
let calldata_hex = format!("0x{}", bytes_to_hex(&calldata));
let result = eth_call(REGISTRY_ADDRESS, &calldata_hex).await?;
Ok(decode_u256_as_u64(&result).map(|v| v != 0).unwrap_or(false))
}
pub fn random_x402_nonce() -> [u8; 32] {
use rand_core::RngCore;
let mut n = [0u8; 32];
rand_core::OsRng.fill_bytes(&mut n);
n
}
pub async fn lh_allowance(owner_hex: &str, spender_hex: &str) -> Result<u128, String> {
if LOCALHARNESS_TOKEN_ADDRESS == zero_address() {
return Ok(0);
}
let owner = parse_eth_address(owner_hex)?;
let spender = parse_eth_address(spender_hex)?;
let mut calldata = Vec::with_capacity(4 + 64);
calldata.extend_from_slice(&selector("allowance(address,address)"));
calldata.extend_from_slice(&addr_word(&owner));
calldata.extend_from_slice(&addr_word(&spender));
let calldata_hex = format!("0x{}", bytes_to_hex(&calldata));
let result = eth_call(LOCALHARNESS_TOKEN_ADDRESS, &calldata_hex).await?;
decode_u256_as_u128(&result)
}
pub async fn approve_lh_sponsored(
sender: &SigningKey,
fee_payer: &SigningKey,
spender_hex: &str,
amount_wei: u128,
fee_token: &str,
) -> Result<String, String> {
let token_addr = parse_eth_address(LOCALHARNESS_TOKEN_ADDRESS)?;
let spender = parse_eth_address(spender_hex)?;
let approve_call = crate::tempo_tx::TempoCall {
to: token_addr,
value_wei: 0,
input: encode_approve(&spender, amount_wei),
};
submit_tempo_sponsored(sender, fee_payer, vec![approve_call], fee_token, 300_000).await
}
pub async fn transfer_lh_sponsored(
sender: &SigningKey,
fee_payer: &SigningKey,
to_hex: &str,
amount_wei: u128,
fee_token: &str,
) -> Result<String, String> {
let token_addr = parse_eth_address(LOCALHARNESS_TOKEN_ADDRESS)?;
let to = parse_eth_address(to_hex)?;
let transfer_call = crate::tempo_tx::TempoCall {
to: token_addr,
value_wei: 0,
input: encode_transfer(&to, amount_wei),
};
submit_tempo_sponsored(sender, fee_payer, vec![transfer_call], fee_token, 300_000).await
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ScheduledJob {
pub owner: String,
pub interval: u64,
pub status: u8,
pub next_run: u64,
pub budget_wei: u128,
pub runs_left: u32,
pub target_id: u64,
}
impl ScheduledJob {
pub fn status_label(&self) -> &'static str {
match self.status {
0 => "active",
1 => "paused",
2 => "cancelled",
3 => "exhausted",
_ => "unknown",
}
}
}
fn encode_schedule_job(
target_id: u64,
task: &[u8],
interval_secs: u64,
budget_wei: u128,
max_runs: u32,
) -> Vec<u8> {
let padded_len = task.len().div_ceil(32) * 32;
let mut out = Vec::with_capacity(4 + 5 * 32 + 32 + padded_len);
out.extend_from_slice(&selector("scheduleJob(uint256,bytes,uint64,uint128,uint32)"));
out.extend_from_slice(&u256_be(target_id as u128));
out.extend_from_slice(&u256_be(5 * 32));
out.extend_from_slice(&u256_be(interval_secs as u128));
out.extend_from_slice(&u256_be(budget_wei));
out.extend_from_slice(&u256_be(max_runs as u128));
out.extend_from_slice(&u256_be(task.len() as u128));
out.extend_from_slice(task);
out.resize(out.len() + (padded_len - task.len()), 0);
out
}
fn encode_cancel_job(job_id: u64) -> Vec<u8> {
let mut out = Vec::with_capacity(4 + 32);
out.extend_from_slice(&selector("cancelJob(uint256)"));
out.extend_from_slice(&u256_be(job_id as u128));
out
}
#[allow(clippy::too_many_arguments)]
pub async fn schedule_job_sponsored(
sender: &SigningKey,
fee_payer: &SigningKey,
target_id: u64,
task: &[u8],
interval_secs: u64,
budget_wei: u128,
max_runs: u32,
fee_token: &str,
) -> Result<String, String> {
let diamond_addr = parse_eth_address(REGISTRY_ADDRESS)?;
let token_addr = parse_eth_address(LOCALHARNESS_TOKEN_ADDRESS)?;
let approve_call = crate::tempo_tx::TempoCall {
to: token_addr,
value_wei: 0,
input: encode_approve(&diamond_addr, budget_wei),
};
let schedule_call = crate::tempo_tx::TempoCall {
to: diamond_addr,
value_wei: 0,
input: encode_schedule_job(target_id, task, interval_secs, budget_wei, max_runs),
};
let gas = 3_500_000 + (task.len() as u128) * 9_000;
submit_tempo_sponsored(sender, fee_payer, vec![approve_call, schedule_call], fee_token, gas).await
}
pub async fn cancel_job_sponsored(
sender: &SigningKey,
fee_payer: &SigningKey,
job_id: u64,
fee_token: &str,
) -> Result<String, String> {
let diamond_addr = parse_eth_address(REGISTRY_ADDRESS)?;
let call = crate::tempo_tx::TempoCall {
to: diamond_addr,
value_wei: 0,
input: encode_cancel_job(job_id),
};
submit_tempo_sponsored(sender, fee_payer, vec![call], fee_token, 400_000).await
}
pub async fn jobs_of(owner_hex: &str) -> Result<Vec<u64>, String> {
if REGISTRY_ADDRESS == zero_address() {
return Ok(Vec::new());
}
let owner = parse_eth_address(owner_hex)?;
let mut calldata = selector("jobsOf(address)").to_vec();
calldata.extend_from_slice(&addr_word(&owner));
let calldata_hex = format!("0x{}", bytes_to_hex(&calldata));
let result = eth_call(REGISTRY_ADDRESS, &calldata_hex).await?;
let bytes = hex_to_bytes(&result)?;
if bytes.len() < 64 {
return Ok(Vec::new());
}
let mut len_buf = [0u8; 8];
len_buf.copy_from_slice(&bytes[56..64]); let len = u64::from_be_bytes(len_buf) as usize;
let mut out = Vec::new();
for i in 0..len {
let start = match i.checked_mul(32).and_then(|o| o.checked_add(64)) {
Some(s) => s,
None => break,
};
let Some(word) = start.checked_add(32).and_then(|end| bytes.get(start + 24..end)) else {
break;
};
let mut id_buf = [0u8; 8];
id_buf.copy_from_slice(word);
out.push(u64::from_be_bytes(id_buf));
}
Ok(out)
}
pub async fn get_job(job_id: u64) -> Result<ScheduledJob, String> {
let calldata = call_uint("getJob(uint256)", job_id);
let result = eth_call(REGISTRY_ADDRESS, &calldata).await?;
let bytes = hex_to_bytes(&result)?;
if bytes.len() < 7 * 32 {
return Err(format!("getJob: short response {} bytes", bytes.len()));
}
let word = |i: usize| &bytes[i * 32..(i + 1) * 32];
let owner = format!("0x{}", bytes_to_hex(&word(0)[12..32])); let u64_low = |w: &[u8]| {
let mut b = [0u8; 8];
b.copy_from_slice(&w[24..32]);
u64::from_be_bytes(b)
};
let u128_low = |w: &[u8]| {
let mut b = [0u8; 16];
b.copy_from_slice(&w[16..32]);
u128::from_be_bytes(b)
};
Ok(ScheduledJob {
owner,
interval: u64_low(word(1)),
status: bytes[2 * 32 + 31], next_run: u64_low(word(3)),
budget_wei: u128_low(word(4)),
runs_left: u64_low(word(5)) as u32,
target_id: u64_low(word(6)),
})
}
pub async fn task_of(job_id: u64) -> Result<String, String> {
let calldata = call_uint("taskOf(uint256)", job_id);
let result = eth_call(REGISTRY_ADDRESS, &calldata).await?;
let raw = hex_to_bytes(&result)?;
if raw.len() < 64 {
return Err(format!("taskOf: short response {} bytes", raw.len()));
}
let len = u64::from_be_bytes(
raw[56..64].try_into().map_err(|e: std::array::TryFromSliceError| e.to_string())?,
) as usize;
let end = len
.checked_add(64)
.filter(|&end| end <= raw.len())
.ok_or_else(|| format!("taskOf: truncated body (len {}, have {})", len, raw.len()))?;
String::from_utf8(raw[64..end].to_vec()).map_err(|e| e.to_string())
}
pub fn invite_code_hash(code: &str) -> [u8; 32] {
keccak_key(code.as_bytes())
}
fn encode_create_invite(code_hash: &[u8; 32], amount_wei: u128, ttl_secs: u64) -> Vec<u8> {
let mut out = Vec::with_capacity(4 + 3 * 32);
out.extend_from_slice(&selector("createInvite(bytes32,uint256,uint64)"));
out.extend_from_slice(code_hash);
out.extend_from_slice(&u256_be(amount_wei));
out.extend_from_slice(&u256_be(ttl_secs as u128));
out
}
fn encode_accept_invite(code: &str) -> Vec<u8> {
let bytes = code.as_bytes();
let len = bytes.len();
let padded_len = len.div_ceil(32) * 32;
let mut out = Vec::with_capacity(4 + 32 + 32 + padded_len);
out.extend_from_slice(&selector("acceptInvite(string)"));
out.extend_from_slice(&u256_be(0x20));
out.extend_from_slice(&u256_be(len as u128));
out.extend_from_slice(bytes);
out.resize(4 + 32 + 32 + padded_len, 0);
out
}
fn encode_reclaim_invite(code_hash: &[u8; 32]) -> Vec<u8> {
let mut out = Vec::with_capacity(4 + 32);
out.extend_from_slice(&selector("reclaimInvite(bytes32)"));
out.extend_from_slice(code_hash);
out
}
#[allow(clippy::too_many_arguments)]
pub async fn create_invite_sponsored(
sender: &SigningKey,
fee_payer: &SigningKey,
code_hash: [u8; 32],
amount_wei: u128,
ttl_secs: u64,
fee_token: &str,
) -> Result<String, String> {
let diamond_addr = parse_eth_address(REGISTRY_ADDRESS)?;
let token_addr = parse_eth_address(LOCALHARNESS_TOKEN_ADDRESS)?;
let approve_call = crate::tempo_tx::TempoCall {
to: token_addr,
value_wei: 0,
input: encode_approve(&diamond_addr, amount_wei),
};
let create_call = crate::tempo_tx::TempoCall {
to: diamond_addr,
value_wei: 0,
input: encode_create_invite(&code_hash, amount_wei, ttl_secs),
};
submit_tempo_sponsored(sender, fee_payer, vec![approve_call, create_call], fee_token, 2_500_000)
.await
}
pub async fn accept_invite_sponsored(
sender: &SigningKey,
fee_payer: &SigningKey,
code: &str,
fee_token: &str,
) -> Result<String, String> {
let diamond_addr = parse_eth_address(REGISTRY_ADDRESS)?;
let call = crate::tempo_tx::TempoCall {
to: diamond_addr,
value_wei: 0,
input: encode_accept_invite(code),
};
submit_tempo_sponsored(sender, fee_payer, vec![call], fee_token, 2_000_000).await
}
pub async fn reclaim_invite_sponsored(
sender: &SigningKey,
fee_payer: &SigningKey,
code_hash: [u8; 32],
fee_token: &str,
) -> Result<String, String> {
let diamond_addr = parse_eth_address(REGISTRY_ADDRESS)?;
let call = crate::tempo_tx::TempoCall {
to: diamond_addr,
value_wei: 0,
input: encode_reclaim_invite(&code_hash),
};
submit_tempo_sponsored(sender, fee_payer, vec![call], fee_token, 600_000).await
}
pub async fn escrowed_of(account_hex: &str) -> Result<u128, String> {
if REGISTRY_ADDRESS == zero_address() {
return Ok(0);
}
let account = parse_eth_address(account_hex)?;
let mut calldata = selector("escrowedOf(address)").to_vec();
calldata.extend_from_slice(&addr_word(&account));
let calldata_hex = format!("0x{}", bytes_to_hex(&calldata));
let result = eth_call(REGISTRY_ADDRESS, &calldata_hex).await?;
decode_u256_as_u128(&result)
}
pub async fn get_invite(code_hash: [u8; 32]) -> Result<(String, u128, u64, u8), String> {
let mut calldata = selector("getInvite(bytes32)").to_vec();
calldata.extend_from_slice(&code_hash);
let calldata_hex = format!("0x{}", bytes_to_hex(&calldata));
let result = eth_call(REGISTRY_ADDRESS, &calldata_hex).await?;
let bytes = hex_to_bytes(&result)?;
if bytes.len() < 4 * 32 {
return Err(format!("getInvite: short response {} bytes", bytes.len()));
}
let word = |i: usize| &bytes[i * 32..(i + 1) * 32];
let funder = format!("0x{}", bytes_to_hex(&word(0)[12..32])); let mut amt = [0u8; 16];
amt.copy_from_slice(&word(1)[16..32]); let amount = u128::from_be_bytes(amt);
let mut exp = [0u8; 8];
exp.copy_from_slice(&word(2)[24..32]); let expiry = u64::from_be_bytes(exp);
let status = word(3)[31]; Ok((funder, amount, expiry, status))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Bounty {
pub poster: String,
pub reward_wei: u128,
pub expiry: u64,
pub status: u8,
pub claimant_token_id: u64,
}
impl Bounty {
pub fn status_label(&self) -> &'static str {
match self.status {
0 => "open",
1 => "claimed",
2 => "submitted",
3 => "paid",
4 => "cancelled",
5 => "reclaimed",
_ => "unknown",
}
}
}
fn encode_post_bounty(task: &[u8], reward_wei: u128, ttl_secs: u64) -> Vec<u8> {
let padded_len = task.len().div_ceil(32) * 32;
let mut out = Vec::with_capacity(4 + 3 * 32 + 32 + padded_len);
out.extend_from_slice(&selector("postBounty(bytes,uint128,uint64)"));
out.extend_from_slice(&u256_be(3 * 32));
out.extend_from_slice(&u256_be(reward_wei));
out.extend_from_slice(&u256_be(ttl_secs as u128));
out.extend_from_slice(&u256_be(task.len() as u128));
out.extend_from_slice(task);
out.resize(out.len() + (padded_len - task.len()), 0);
out
}
fn encode_claim_bounty(bounty_id: u64, claimant_token_id: u64) -> Vec<u8> {
let mut out = Vec::with_capacity(4 + 64);
out.extend_from_slice(&selector("claimBounty(uint256,uint256)"));
out.extend_from_slice(&u256_be(bounty_id as u128));
out.extend_from_slice(&u256_be(claimant_token_id as u128));
out
}
fn encode_submit_result(bounty_id: u64, result: &[u8]) -> Vec<u8> {
let padded_len = result.len().div_ceil(32) * 32;
let mut out = Vec::with_capacity(4 + 2 * 32 + 32 + padded_len);
out.extend_from_slice(&selector("submitResult(uint256,bytes)"));
out.extend_from_slice(&u256_be(bounty_id as u128));
out.extend_from_slice(&u256_be(2 * 32)); out.extend_from_slice(&u256_be(result.len() as u128));
out.extend_from_slice(result);
out.resize(out.len() + (padded_len - result.len()), 0);
out
}
#[allow(clippy::too_many_arguments)]
pub async fn post_bounty_sponsored(
sender: &SigningKey,
fee_payer: &SigningKey,
task: &[u8],
reward_wei: u128,
ttl_secs: u64,
fee_token: &str,
) -> Result<String, String> {
let diamond_addr = parse_eth_address(REGISTRY_ADDRESS)?;
let token_addr = parse_eth_address(LOCALHARNESS_TOKEN_ADDRESS)?;
let approve_call = crate::tempo_tx::TempoCall {
to: token_addr,
value_wei: 0,
input: encode_approve(&diamond_addr, reward_wei),
};
let post_call = crate::tempo_tx::TempoCall {
to: diamond_addr,
value_wei: 0,
input: encode_post_bounty(task, reward_wei, ttl_secs),
};
let gas = 3_500_000 + (task.len() as u128) * 9_000;
submit_tempo_sponsored(sender, fee_payer, vec![approve_call, post_call], fee_token, gas).await
}
pub async fn claim_bounty_sponsored(
sender: &SigningKey,
fee_payer: &SigningKey,
bounty_id: u64,
claimant_token_id: u64,
fee_token: &str,
) -> Result<String, String> {
let diamond_addr = parse_eth_address(REGISTRY_ADDRESS)?;
let call = crate::tempo_tx::TempoCall {
to: diamond_addr,
value_wei: 0,
input: encode_claim_bounty(bounty_id, claimant_token_id),
};
submit_tempo_sponsored(sender, fee_payer, vec![call], fee_token, 400_000).await
}
pub async fn submit_result_sponsored(
sender: &SigningKey,
fee_payer: &SigningKey,
bounty_id: u64,
result: &[u8],
fee_token: &str,
) -> Result<String, String> {
let diamond_addr = parse_eth_address(REGISTRY_ADDRESS)?;
let call = crate::tempo_tx::TempoCall {
to: diamond_addr,
value_wei: 0,
input: encode_submit_result(bounty_id, result),
};
let gas = 1_200_000 + (result.len() as u128) * 9_000;
submit_tempo_sponsored(sender, fee_payer, vec![call], fee_token, gas).await
}
pub async fn accept_result_sponsored(
sender: &SigningKey,
fee_payer: &SigningKey,
bounty_id: u64,
fee_token: &str,
) -> Result<String, String> {
let diamond_addr = parse_eth_address(REGISTRY_ADDRESS)?;
let call = crate::tempo_tx::TempoCall {
to: diamond_addr,
value_wei: 0,
input: call_uint_bytes("acceptResult(uint256)", bounty_id),
};
submit_tempo_sponsored(sender, fee_payer, vec![call], fee_token, 2_000_000).await
}
pub async fn cancel_bounty_sponsored(
sender: &SigningKey,
fee_payer: &SigningKey,
bounty_id: u64,
fee_token: &str,
) -> Result<String, String> {
let diamond_addr = parse_eth_address(REGISTRY_ADDRESS)?;
let call = crate::tempo_tx::TempoCall {
to: diamond_addr,
value_wei: 0,
input: call_uint_bytes("cancelBounty(uint256)", bounty_id),
};
submit_tempo_sponsored(sender, fee_payer, vec![call], fee_token, 600_000).await
}
pub async fn reclaim_expired_sponsored(
sender: &SigningKey,
fee_payer: &SigningKey,
bounty_id: u64,
fee_token: &str,
) -> Result<String, String> {
let diamond_addr = parse_eth_address(REGISTRY_ADDRESS)?;
let call = crate::tempo_tx::TempoCall {
to: diamond_addr,
value_wei: 0,
input: call_uint_bytes("reclaimExpired(uint256)", bounty_id),
};
submit_tempo_sponsored(sender, fee_payer, vec![call], fee_token, 600_000).await
}
pub async fn open_bounties(start_after: u64, limit: u64) -> Result<Vec<u64>, String> {
if REGISTRY_ADDRESS == zero_address() {
return Ok(Vec::new());
}
let mut calldata = selector("openBounties(uint256,uint256)").to_vec();
calldata.extend_from_slice(&u256_be(start_after as u128));
calldata.extend_from_slice(&u256_be(limit as u128));
let calldata_hex = format!("0x{}", bytes_to_hex(&calldata));
let result = eth_call(REGISTRY_ADDRESS, &calldata_hex).await?;
let bytes = hex_to_bytes(&result)?;
decode_uint_array_with_cursor(&bytes)
}
fn decode_uint_array_with_cursor(bytes: &[u8]) -> Result<Vec<u64>, String> {
if bytes.len() < 64 {
return Ok(Vec::new());
}
let mut off_buf = [0u8; 8];
off_buf.copy_from_slice(&bytes[24..32]);
let arr_off = u64::from_be_bytes(off_buf) as usize;
let len_start = match arr_off.checked_add(32) {
Some(s) if s <= bytes.len() => arr_off,
_ => return Ok(Vec::new()),
};
let mut len_buf = [0u8; 8];
len_buf.copy_from_slice(&bytes[len_start + 24..len_start + 32]);
let len = u64::from_be_bytes(len_buf) as usize;
let body = len_start + 32; let mut out = Vec::new();
for i in 0..len {
let start = match i.checked_mul(32).and_then(|o| o.checked_add(body)) {
Some(s) => s,
None => break,
};
let Some(word) = start.checked_add(32).and_then(|end| bytes.get(start + 24..end)) else {
break;
};
let mut id_buf = [0u8; 8];
id_buf.copy_from_slice(word);
out.push(u64::from_be_bytes(id_buf));
}
Ok(out)
}
pub async fn bounties_of(account_hex: &str) -> Result<Vec<u64>, String> {
if REGISTRY_ADDRESS == zero_address() {
return Ok(Vec::new());
}
let account = parse_eth_address(account_hex)?;
let mut calldata = selector("bountiesOf(address)").to_vec();
calldata.extend_from_slice(&addr_word(&account));
let calldata_hex = format!("0x{}", bytes_to_hex(&calldata));
let result = eth_call(REGISTRY_ADDRESS, &calldata_hex).await?;
let bytes = hex_to_bytes(&result)?;
if bytes.len() < 64 {
return Ok(Vec::new());
}
let mut len_buf = [0u8; 8];
len_buf.copy_from_slice(&bytes[56..64]);
let len = u64::from_be_bytes(len_buf) as usize;
let mut out = Vec::new();
for i in 0..len {
let start = match i.checked_mul(32).and_then(|o| o.checked_add(64)) {
Some(s) => s,
None => break,
};
let Some(word) = start.checked_add(32).and_then(|end| bytes.get(start + 24..end)) else {
break;
};
let mut id_buf = [0u8; 8];
id_buf.copy_from_slice(word);
out.push(u64::from_be_bytes(id_buf));
}
Ok(out)
}
pub async fn get_bounty(bounty_id: u64) -> Result<Bounty, String> {
let calldata = call_uint("getBounty(uint256)", bounty_id);
let result = eth_call(REGISTRY_ADDRESS, &calldata).await?;
let bytes = hex_to_bytes(&result)?;
if bytes.len() < 5 * 32 {
return Err(format!("getBounty: short response {} bytes", bytes.len()));
}
let word = |i: usize| &bytes[i * 32..(i + 1) * 32];
let poster = format!("0x{}", bytes_to_hex(&word(0)[12..32])); let u64_low = |w: &[u8]| {
let mut b = [0u8; 8];
b.copy_from_slice(&w[24..32]);
u64::from_be_bytes(b)
};
let mut amt = [0u8; 16];
amt.copy_from_slice(&word(1)[16..32]); Ok(Bounty {
poster,
reward_wei: u128::from_be_bytes(amt),
expiry: u64_low(word(2)),
status: bytes[3 * 32 + 31], claimant_token_id: u64_low(word(4)),
})
}
pub async fn task_of_bounty(bounty_id: u64) -> Result<String, String> {
decode_bytes_string_call("bountyTaskOf(uint256)", bounty_id, "bountyTaskOf").await
}
pub async fn result_of_bounty(bounty_id: u64) -> Result<String, String> {
decode_bytes_string_call("resultOf(uint256)", bounty_id, "resultOf").await
}
async fn decode_bytes_string_call(sig: &str, id: u64, what: &str) -> Result<String, String> {
let calldata = call_uint(sig, id);
let result = eth_call(REGISTRY_ADDRESS, &calldata).await?;
let raw = hex_to_bytes(&result)?;
if raw.len() < 64 {
return Err(format!("{what}: short response {} bytes", raw.len()));
}
let len = u64::from_be_bytes(
raw[56..64].try_into().map_err(|e: std::array::TryFromSliceError| e.to_string())?,
) as usize;
let end = len
.checked_add(64)
.filter(|&end| end <= raw.len())
.ok_or_else(|| format!("{what}: truncated body (len {}, have {})", len, raw.len()))?;
String::from_utf8(raw[64..end].to_vec()).map_err(|e| e.to_string())
}
fn call_uint_bytes(sig: &str, id: u64) -> Vec<u8> {
let mut data = Vec::with_capacity(4 + 32);
data.extend_from_slice(&selector(sig));
data.extend_from_slice(&u256_be(id as u128));
data
}
pub async fn discover_bounties(query: &str, scan: u64) -> Result<Vec<(u64, String, u128)>, String> {
let ids = open_bounties(0, scan).await?;
if ids.is_empty() {
return Ok(Vec::new());
}
let mut entries: Vec<(u64, String, u128)> = Vec::with_capacity(ids.len());
let mut pairs: Vec<(String, String)> = Vec::with_capacity(ids.len());
for id in ids {
let task = task_of_bounty(id).await.unwrap_or_default();
let reward = get_bounty(id).await.map(|b| b.reward_wei).unwrap_or(0);
pairs.push((id.to_string(), task.clone()));
entries.push((id, task, reward));
}
let ranked = rank_agent_matches(&pairs, query);
let mut out: Vec<(u64, String, u128)> = Vec::with_capacity(ranked.len());
for (id_str, _task) in ranked {
if let Some(entry) = entries.iter().find(|(id, _, _)| id.to_string() == id_str) {
out.push(entry.clone());
}
}
Ok(out)
}
#[cfg(test)]
mod x402_tests {
use super::*;
#[test]
fn x402_domain_matches_live_facet() {
let expected =
"54530933a67f96286ac528dbff39d00c0ea49f4c6bd0f034343a0c78927f0b7a";
let got = x402_domain_separator().unwrap();
assert_eq!(bytes_to_hex(&got), expected);
}
#[test]
fn x402_sign_recovers_payer() {
let w = crate::wallet::generate();
let from = w.address;
let to = [0x11u8; 20];
let nonce = [0x22u8; 32];
let sig = sign_x402(&w.signer, &from, &to, 1_000, 0, 9_999_999_999, &nonce).unwrap();
let digest = x402_digest(&from, &to, 1_000, 0, 9_999_999_999, &nonce).unwrap();
let recovered = crate::wallet::recover_address(&sig, &digest).unwrap();
assert_eq!(recovered, from);
}
#[test]
fn erc721_transfer_from_calldata_layout() {
let from = [0xAAu8; 20];
let to = [0xBBu8; 20];
let cd = encode_erc721_transfer_from(&from, &to, 0x1234);
assert_eq!(&cd[0..4], &[0x23, 0xb8, 0x72, 0xdd]);
assert_eq!(cd.len(), 4 + 96);
assert_eq!(&cd[4 + 12..4 + 32], &from); assert_eq!(&cd[4 + 44..4 + 64], &to); assert_eq!(u64::from_be_bytes(cd[4 + 88..4 + 96].try_into().unwrap()), 0x1234);
}
#[test]
fn release_name_calldata_layout() {
let cd = encode_release_name(7);
assert_eq!(&cd[0..4], &selector("releaseName(uint256)"));
assert_eq!(cd.len(), 36);
assert_eq!(u64::from_be_bytes(cd[28..36].try_into().unwrap()), 7);
}
#[test]
fn link_unlink_device_calldata_layout() {
let dev = [0xCDu8; 20];
let link = encode_link_device(3, &dev);
assert_eq!(&link[0..4], &selector("linkDevice(uint256,address)"));
assert_eq!(link.len(), 68);
assert_eq!(u64::from_be_bytes(link[28..36].try_into().unwrap()), 3); assert_eq!(&link[36 + 12..36 + 32], &dev); let unlink = encode_unlink_device(3, &dev);
assert_eq!(&unlink[0..4], &selector("unlinkDevice(uint256,address)"));
assert_eq!(unlink.len(), 68);
assert_eq!(&unlink[36 + 12..36 + 32], &dev);
}
#[test]
fn deposit_credits_calldata_layout() {
let cd = encode_deposit_credits(1_000_000_000_000_000_000);
assert_eq!(&cd[0..4], &selector("depositCredits(uint256)"));
assert_eq!(cd.len(), 36);
}
#[test]
fn transfer_calldata_layout() {
let to = [0xFFu8; 20];
let amount = 1_500_000_000_000_000_000u128; let cd = encode_transfer(&to, amount);
assert_eq!(&cd[0..4], &[0xa9, 0x05, 0x9c, 0xbb]);
assert_eq!(cd.len(), 4 + 64);
assert_eq!(&cd[4..4 + 12], &[0u8; 12]);
assert_eq!(&cd[4 + 12..4 + 32], &to);
assert_eq!(&cd[4 + 32..4 + 48], &[0u8; 16]);
assert_eq!(
u128::from_be_bytes(cd[4 + 48..4 + 64].try_into().unwrap()),
amount
);
}
#[test]
fn approve_calldata_layout_max_amount() {
let spender = [0xABu8; 20];
let cd = encode_approve(&spender, u128::MAX);
assert_eq!(&cd[0..4], &[0x09, 0x5e, 0xa7, 0xb3]);
assert_eq!(cd.len(), 4 + 64);
assert_eq!(&cd[4 + 12..4 + 32], &spender);
assert_eq!(&cd[4 + 32..4 + 48], &[0u8; 16]);
assert_eq!(&cd[4 + 48..4 + 64], &[0xFFu8; 16]);
}
#[test]
fn settle_calldata_layout() {
let from = [0x11u8; 20];
let to = [0x22u8; 20];
let nonce = [0x33u8; 32];
let sig = [0x44u8; 65];
let value = 7_000u128;
let cd = encode_settle(&from, &to, value, 1, 2, &nonce, &sig);
assert_eq!(
&cd[0..4],
&selector("settle(address,address,uint256,uint256,uint256,bytes32,bytes)")
);
assert_eq!(cd.len(), 4 + 6 * 32 + 32 + 32 + 96);
assert_eq!(&cd[4 + 12..4 + 32], &from); assert_eq!(&cd[4 + 44..4 + 64], &to); assert_eq!(u128::from_be_bytes(cd[4 + 80..4 + 96].try_into().unwrap()), value); assert_eq!(&cd[4 + 5 * 32..4 + 6 * 32], &nonce); assert_eq!(u64::from_be_bytes(cd[4 + 6 * 32 + 24..4 + 7 * 32].try_into().unwrap()), 7 * 32);
assert_eq!(u64::from_be_bytes(cd[4 + 7 * 32 + 24..4 + 8 * 32].try_into().unwrap()), 65);
assert_eq!(&cd[4 + 8 * 32..4 + 8 * 32 + 65], &sig);
assert_eq!(&cd[4 + 8 * 32 + 65..], &[0u8; 31]);
}
#[test]
fn schedule_job_calldata_layout() {
let task = b"ping the oracle"; let cd = encode_schedule_job(0x42, task, 300, 1_500_000_000_000_000_000u128, 100);
assert_eq!(&cd[0..4], &selector("scheduleJob(uint256,bytes,uint64,uint128,uint32)"));
assert_eq!(cd.len(), 4 + 5 * 32 + 32 + 32);
assert_eq!(u64::from_be_bytes(cd[4 + 24..4 + 32].try_into().unwrap()), 0x42);
assert_eq!(u64::from_be_bytes(cd[4 + 32 + 24..4 + 2 * 32].try_into().unwrap()), 5 * 32);
assert_eq!(u64::from_be_bytes(cd[4 + 2 * 32 + 24..4 + 3 * 32].try_into().unwrap()), 300);
assert_eq!(
u128::from_be_bytes(cd[4 + 3 * 32 + 16..4 + 4 * 32].try_into().unwrap()),
1_500_000_000_000_000_000u128
);
assert_eq!(u64::from_be_bytes(cd[4 + 4 * 32 + 24..4 + 5 * 32].try_into().unwrap()), 100);
assert_eq!(
u64::from_be_bytes(cd[4 + 5 * 32 + 24..4 + 6 * 32].try_into().unwrap()),
task.len() as u64
);
assert_eq!(&cd[4 + 6 * 32..4 + 6 * 32 + task.len()], task);
assert_eq!(&cd[4 + 6 * 32 + task.len()..], &[0u8; 32 - 15]);
}
#[test]
fn schedule_job_task_exact_multiple_no_extra_pad() {
let task = [0xABu8; 32];
let cd = encode_schedule_job(1, &task, 60, 1, 1);
assert_eq!(cd.len(), 4 + 5 * 32 + 32 + 32);
assert_eq!(&cd[4 + 6 * 32..], &task);
}
#[test]
fn cancel_job_calldata_layout() {
let cd = encode_cancel_job(9);
assert_eq!(&cd[0..4], &selector("cancelJob(uint256)"));
assert_eq!(cd.len(), 36);
assert_eq!(u64::from_be_bytes(cd[28..36].try_into().unwrap()), 9);
}
#[test]
fn scheduled_job_status_label_maps_enum() {
let mut j = ScheduledJob {
owner: "0x00".into(),
interval: 60,
status: 0,
next_run: 0,
budget_wei: 0,
runs_left: 0,
target_id: 0,
};
assert_eq!(j.status_label(), "active");
j.status = 1;
assert_eq!(j.status_label(), "paused");
j.status = 2;
assert_eq!(j.status_label(), "cancelled");
j.status = 3;
assert_eq!(j.status_label(), "exhausted");
j.status = 9;
assert_eq!(j.status_label(), "unknown");
}
#[test]
fn rank_agent_matches_hostile_inputs() {
assert!(rank_agent_matches(&[], "anything").is_empty());
assert!(rank_agent_matches(&[], "").is_empty());
let agents = vec![
("auditor".to_string(), "reviews code".to_string()),
("bob".to_string(), "I AUDIT contracts".to_string()),
("carol".to_string(), "unrelated".to_string()),
];
let hits = rank_agent_matches(&agents, "audit");
assert_eq!(hits.len(), 2);
assert_eq!(hits[0].0, "auditor"); assert_eq!(hits[1].0, "bob"); assert_eq!(rank_agent_matches(&agents, " AUDIT ").len(), 2);
let all = rank_agent_matches(&agents, "\t \n");
assert_eq!(all.len(), 3);
assert_eq!(all[0].0, "auditor");
let dual = vec![("audit".to_string(), "audit audit".to_string())];
assert_eq!(rank_agent_matches(&dual, "audit").len(), 1);
}
#[test]
fn post_bounty_calldata_layout() {
let task = b"audit my solidity contract"; let reward = 5_000_000_000_000_000_000u128; let cd = encode_post_bounty(task, reward, 86_400);
assert_eq!(&cd[0..4], &selector("postBounty(bytes,uint128,uint64)"));
assert_eq!(cd.len(), 4 + 3 * 32 + 32 + 32);
assert_eq!(u64::from_be_bytes(cd[4 + 24..4 + 32].try_into().unwrap()), 3 * 32);
assert_eq!(
u128::from_be_bytes(cd[4 + 32 + 16..4 + 2 * 32].try_into().unwrap()),
reward
);
assert_eq!(u64::from_be_bytes(cd[4 + 2 * 32 + 24..4 + 3 * 32].try_into().unwrap()), 86_400);
assert_eq!(
u64::from_be_bytes(cd[4 + 3 * 32 + 24..4 + 4 * 32].try_into().unwrap()),
task.len() as u64
);
assert_eq!(&cd[4 + 4 * 32..4 + 4 * 32 + task.len()], task);
assert_eq!(&cd[4 + 4 * 32 + task.len()..], &[0u8; 32 - 26]);
}
#[test]
fn post_bounty_task_exact_multiple_no_extra_pad() {
let task = [0xCDu8; 64];
let cd = encode_post_bounty(&task, 1, 60);
assert_eq!(cd.len(), 4 + 3 * 32 + 32 + 64);
assert_eq!(&cd[4 + 4 * 32..], &task);
}
#[test]
fn claim_bounty_calldata_layout() {
let cd = encode_claim_bounty(7, 42);
assert_eq!(&cd[0..4], &selector("claimBounty(uint256,uint256)"));
assert_eq!(cd.len(), 4 + 64);
assert_eq!(u64::from_be_bytes(cd[4 + 24..4 + 32].try_into().unwrap()), 7); assert_eq!(u64::from_be_bytes(cd[4 + 32 + 24..4 + 2 * 32].try_into().unwrap()), 42); }
#[test]
fn submit_result_calldata_layout() {
let result = b"done: see ipfs://Qm..."; let cd = encode_submit_result(3, result);
assert_eq!(&cd[0..4], &selector("submitResult(uint256,bytes)"));
assert_eq!(cd.len(), 4 + 2 * 32 + 32 + 32);
assert_eq!(u64::from_be_bytes(cd[4 + 24..4 + 32].try_into().unwrap()), 3);
assert_eq!(u64::from_be_bytes(cd[4 + 32 + 24..4 + 2 * 32].try_into().unwrap()), 2 * 32);
assert_eq!(
u64::from_be_bytes(cd[4 + 2 * 32 + 24..4 + 3 * 32].try_into().unwrap()),
result.len() as u64
);
assert_eq!(&cd[4 + 3 * 32..4 + 3 * 32 + result.len()], result);
assert_eq!(&cd[4 + 3 * 32 + result.len()..], &[0u8; 32 - 22]);
}
#[test]
fn single_arg_bounty_calldata_layouts() {
for sig in [
"acceptResult(uint256)",
"cancelBounty(uint256)",
"reclaimExpired(uint256)",
] {
let cd = call_uint_bytes(sig, 11);
assert_eq!(&cd[0..4], &selector(sig));
assert_eq!(cd.len(), 36);
assert_eq!(u64::from_be_bytes(cd[28..36].try_into().unwrap()), 11);
}
}
#[test]
fn open_bounties_cursor_decode() {
let mut bytes = Vec::new();
bytes.extend_from_slice(&u256_be(64));
bytes.extend_from_slice(&u256_be(99)); bytes.extend_from_slice(&u256_be(3));
bytes.extend_from_slice(&u256_be(5));
bytes.extend_from_slice(&u256_be(8));
bytes.extend_from_slice(&u256_be(13));
let ids = decode_uint_array_with_cursor(&bytes).unwrap();
assert_eq!(ids, vec![5, 8, 13]);
}
#[test]
fn open_bounties_cursor_decode_hostile() {
assert!(decode_uint_array_with_cursor(&[]).unwrap().is_empty());
assert!(decode_uint_array_with_cursor(&[0u8; 32]).unwrap().is_empty());
let mut bytes = Vec::new();
bytes.extend_from_slice(&u256_be(64));
bytes.extend_from_slice(&u256_be(0));
bytes.extend_from_slice(&u256_be(1000)); bytes.extend_from_slice(&u256_be(7)); let ids = decode_uint_array_with_cursor(&bytes).unwrap();
assert_eq!(ids, vec![7]); }
#[test]
fn bounty_status_label_maps_enum() {
let mut b = Bounty {
poster: "0x00".into(),
reward_wei: 0,
expiry: 0,
status: 0,
claimant_token_id: 0,
};
for (s, label) in [
(0u8, "open"),
(1, "claimed"),
(2, "submitted"),
(3, "paid"),
(4, "cancelled"),
(5, "reclaimed"),
(9, "unknown"),
] {
b.status = s;
assert_eq!(b.status_label(), label);
}
}
#[test]
fn bounty_rank_over_task_text() {
let pairs = vec![
("1".to_string(), "audit a solidity contract".to_string()),
("2".to_string(), "write a poem".to_string()),
("3".to_string(), "SOLIDITY gas review".to_string()),
];
let hits = rank_agent_matches(&pairs, "solidity");
assert_eq!(hits.len(), 2);
assert_eq!(hits[0].0, "1");
assert_eq!(hits[1].0, "3");
assert_eq!(rank_agent_matches(&pairs, "").len(), 3);
}
}
#[cfg(target_arch = "wasm32")]
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"))]
fn log_main_warning(_err: &str) {
}
pub fn rlp_call_unsigned(
to_hex: &str,
value_wei: u128,
data: &[u8],
nonce: u128,
gas_price: u128,
gas_limit: u128,
) -> Result<Vec<u8>, String> {
rlp_legacy_unsigned(nonce, gas_price, gas_limit, to_hex, value_wei, data, CHAIN_ID)
}
pub fn rlp_call_signed(
to_hex: &str,
value_wei: u128,
data: &[u8],
nonce: u128,
gas_price: u128,
gas_limit: u128,
sig_65: &[u8; 65],
) -> Result<String, String> {
let rec_id = (sig_65[64] - 27) as u64;
let v = CHAIN_ID * 2 + 35 + rec_id;
let signed = rlp_legacy_signed(
nonce, gas_price, gas_limit, to_hex, value_wei, data,
v, &sig_65[..32], &sig_65[32..64],
)?;
Ok(format!("0x{}", bytes_to_hex(&signed)))
}
#[allow(clippy::too_many_arguments)]
fn rlp_legacy_unsigned(
nonce: u128,
gas_price: u128,
gas_limit: u128,
to_hex: &str,
value: u128,
data: &[u8],
chain_id: u64,
) -> Result<Vec<u8>, String> {
let to_bytes = hex_to_bytes(to_hex)?;
let items = vec![
wallet::rlp_uint(nonce),
wallet::rlp_uint(gas_price),
wallet::rlp_uint(gas_limit),
wallet::rlp_bytes(&to_bytes),
wallet::rlp_uint(value),
wallet::rlp_bytes(data),
wallet::rlp_uint(chain_id as u128),
wallet::rlp_uint(0),
wallet::rlp_uint(0),
];
Ok(wallet::rlp_list(&items))
}
#[allow(clippy::too_many_arguments)]
fn rlp_legacy_signed(
nonce: u128,
gas_price: u128,
gas_limit: u128,
to_hex: &str,
value: u128,
data: &[u8],
v: u64,
r: &[u8],
s: &[u8],
) -> Result<Vec<u8>, String> {
let to_bytes = hex_to_bytes(to_hex)?;
let r_min = strip_leading_zeros(r);
let s_min = strip_leading_zeros(s);
let items = vec![
wallet::rlp_uint(nonce),
wallet::rlp_uint(gas_price),
wallet::rlp_uint(gas_limit),
wallet::rlp_bytes(&to_bytes),
wallet::rlp_uint(value),
wallet::rlp_bytes(data),
wallet::rlp_uint(v as u128),
wallet::rlp_bytes(r_min),
wallet::rlp_bytes(s_min),
];
Ok(wallet::rlp_list(&items))
}
fn strip_leading_zeros(bytes: &[u8]) -> &[u8] {
let first_nz = bytes.iter().position(|b| *b != 0).unwrap_or(bytes.len() - 1);
&bytes[first_nz..]
}
#[derive(Serialize)]
struct RpcRequest<'a> {
jsonrpc: &'a str,
id: u32,
method: &'a str,
params: serde_json::Value,
}
#[derive(Deserialize)]
struct RpcResponse {
#[serde(default)]
result: Option<serde_json::Value>,
#[serde(default)]
error: Option<RpcError>,
}
#[derive(Deserialize)]
struct RpcError {
#[allow(dead_code)]
code: i64,
message: String,
}
const RPC_TIMEOUT_MS: u32 = 20_000;
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()
}
}
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
)),
}
}
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"))
}
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"))
}
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
}
fn call_uint(sig: &str, id: u64) -> String {
let mut data = Vec::with_capacity(4 + 32);
data.extend_from_slice(&selector(sig));
data.extend_from_slice(&u256_be(id as u128));
format!("0x{}", bytes_to_hex(&data))
}
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()))
}
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()
}
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)
}
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 async fn tba_signers(tba_hex: &str) -> Result<Vec<String>, String> {
use sha3::{Digest, Keccak256};
let added_topic = format!("0x{}", bytes_to_hex(
&Keccak256::digest(b"SignerAdded(address,address)")
));
let removed_topic = format!("0x{}", bytes_to_hex(
&Keccak256::digest(b"SignerRemoved(address,address)")
));
let added_logs = eth_get_logs(
tba_hex,
vec![serde_json::json!(added_topic)],
"0x0",
).await.unwrap_or_default();
let removed_logs = eth_get_logs(
tba_hex,
vec![serde_json::json!(removed_topic)],
"0x0",
).await.unwrap_or_default();
let mut signers = std::collections::HashSet::new();
for log in &added_logs {
if let Some(topics) = log.get("topics").and_then(|t| t.as_array()) {
if let Some(topic) = topics.get(1).and_then(|t| t.as_str()) {
let addr = format!("0x{}", &topic.trim_start_matches("0x")[24..]);
signers.insert(addr.to_lowercase());
}
}
}
for log in &removed_logs {
if let Some(topics) = log.get("topics").and_then(|t| t.as_array()) {
if let Some(topic) = topics.get(1).and_then(|t| t.as_str()) {
let addr = format!("0x{}", &topic.trim_start_matches("0x")[24..]);
signers.remove(&addr.to_lowercase());
}
}
}
Ok(signers.into_iter().collect())
}
#[derive(Debug, Clone)]
pub struct FeedbackEntry {
pub sender: String,
pub timestamp: u64,
pub text: String,
}
pub fn encode_submit_feedback(text: &str) -> Vec<u8> {
let bytes = text.as_bytes();
let len = bytes.len();
let padded = len.div_ceil(32) * 32;
let mut buf = Vec::with_capacity(4 + 64 + padded);
buf.extend_from_slice(&selector("submitFeedback(string)"));
buf.extend_from_slice(&u256_be(0x20));
buf.extend_from_slice(&u256_be(len as u128));
buf.extend_from_slice(bytes);
buf.resize(4 + 64 + padded, 0);
buf
}
pub async fn submit_feedback_sponsored(
sender: &SigningKey,
fee_payer: &SigningKey,
text: &str,
fee_token: &str,
) -> Result<String, String> {
let diamond = parse_eth_address(REGISTRY_ADDRESS)?;
let call = crate::tempo_tx::TempoCall {
to: diamond,
value_wei: 0,
input: encode_submit_feedback(text),
};
let gas = 1_500_000u128 + (text.len() as u128) * 9_000;
submit_tempo_sponsored(sender, fee_payer, vec![call], fee_token, gas).await
}
pub async fn list_feedback() -> Result<Vec<FeedbackEntry>, String> {
use sha3::{Digest, Keccak256};
let topic0 = format!(
"0x{}",
bytes_to_hex(&Keccak256::digest(b"FeedbackSubmitted(address,uint256,string)"))
);
let latest_hex = rpc("eth_blockNumber", serde_json::json!([])).await?;
let latest = parse_hex_quantity(&latest_hex)? as u64;
let from = latest.saturating_sub(99_000);
let from_hex = format!("0x{from:x}");
let logs = eth_get_logs(REGISTRY_ADDRESS, vec![serde_json::json!(topic0)], &from_hex).await?;
let mut out = Vec::new();
for log in &logs {
let sender = log
.get("topics")
.and_then(|t| t.as_array())
.and_then(|t| t.get(1))
.and_then(|t| t.as_str())
.map(|t| format!("0x{}", &t.trim_start_matches("0x")[24..]).to_lowercase())
.unwrap_or_default();
let Some(data_hex) = log.get("data").and_then(|d| d.as_str()) else {
continue;
};
let Ok(bytes) = hex_to_bytes(data_hex) else { continue };
if let Some(entry) = decode_feedback_data(&bytes, sender) {
out.push(entry);
}
}
out.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
Ok(out)
}
fn decode_feedback_data(bytes: &[u8], sender: String) -> Option<FeedbackEntry> {
if bytes.len() < 96 {
return None;
}
let mut ts = [0u8; 8];
ts.copy_from_slice(&bytes[24..32]); let timestamp = u64::from_be_bytes(ts);
let mut len_buf = [0u8; 8];
len_buf.copy_from_slice(&bytes[88..96]); let len = u64::from_be_bytes(len_buf) as usize;
let end = len.checked_add(96)?;
let text_bytes = bytes.get(96..end)?;
let text = String::from_utf8_lossy(text_bytes).into_owned();
Some(FeedbackEntry { sender, timestamp, text })
}
pub fn devices_topic(owner_addr: &str) -> [u8; 32] {
let mut pre = b"localharness.devices".to_vec();
if let Ok(a) = parse_eth_address(owner_addr) {
pre.extend_from_slice(&a);
}
keccak_key(&pre)
}
pub fn team_topic(team_id: u64) -> [u8; 32] {
let mut pre = b"localharness.team".to_vec();
pre.extend_from_slice(&u256_be(team_id as u128));
keccak_key(&pre)
}
fn address_word(addr: &[u8; 20]) -> [u8; 32] {
let mut w = [0u8; 32];
w[12..32].copy_from_slice(addr);
w
}
fn push_abi_bytes(d: &mut Vec<u8>, bytes: &[u8]) {
d.extend_from_slice(&u256_be(bytes.len() as u128));
d.extend_from_slice(bytes);
let pad = (32 - (bytes.len() % 32)) % 32;
d.extend(std::iter::repeat(0u8).take(pad));
}
pub fn announce_digest(topic: &[u8; 32], ephemeral: &[u8; 20], pubkey: &[u8]) -> [u8; 32] {
let mut pre = Vec::with_capacity(32 + 20 + pubkey.len());
pre.extend_from_slice(topic);
pre.extend_from_slice(ephemeral);
pre.extend_from_slice(pubkey);
keccak32(&pre)
}
fn encode_announce(
topic: &[u8; 32],
owner: &[u8; 20],
ephemeral: &[u8; 20],
pubkey: &[u8],
sig: &[u8; 65],
) -> Vec<u8> {
let mut d = selector("announce(bytes32,address,address,bytes,bytes)").to_vec();
d.extend_from_slice(topic);
d.extend_from_slice(&address_word(owner));
d.extend_from_slice(&address_word(ephemeral));
d.extend_from_slice(&u256_be(0xa0)); let pubkey_tail = 32 + ((pubkey.len() + 31) / 32) * 32;
d.extend_from_slice(&u256_be((0xa0 + pubkey_tail) as u128)); push_abi_bytes(&mut d, pubkey);
push_abi_bytes(&mut d, sig);
d
}
#[allow(clippy::too_many_arguments)]
pub async fn announce_sponsored(
sender: &SigningKey,
fee_payer: &SigningKey,
owner_key: &SigningKey,
owner: &[u8; 20],
topic: &[u8; 32],
ephemeral: &[u8; 20],
pubkey: &[u8],
fee_token: &str,
) -> Result<String, String> {
let digest = announce_digest(topic, ephemeral, pubkey);
let sig = crate::wallet::sign_hash(owner_key, &digest); let call = crate::tempo_tx::TempoCall {
to: parse_eth_address(REGISTRY_ADDRESS)?,
value_wei: 0,
input: encode_announce(topic, owner, ephemeral, pubkey, &sig),
};
let gas = 1_200_000u128 + (pubkey.len() as u128) * 9_000;
submit_tempo_sponsored(sender, fee_payer, vec![call], fee_token, gas).await
}
fn encode_post_signal(to: &[u8; 20], blob: &[u8]) -> Vec<u8> {
let mut d = selector("postSignal(address,bytes)").to_vec();
d.extend_from_slice(&address_word(to));
d.extend_from_slice(&u256_be(0x40)); push_abi_bytes(&mut d, blob);
d
}
pub async fn post_signal_sponsored(
sender: &SigningKey,
fee_payer: &SigningKey,
to: &[u8; 20],
blob: &[u8],
fee_token: &str,
) -> Result<String, String> {
let call = crate::tempo_tx::TempoCall {
to: parse_eth_address(REGISTRY_ADDRESS)?,
value_wei: 0,
input: encode_post_signal(to, blob),
};
let gas = 1_200_000u128 + (blob.len() as u128) * 9_000;
submit_tempo_sponsored(sender, fee_payer, vec![call], fee_token, gas).await
}
pub type AddrTsBytes = (String, u64, Vec<u8>);
fn decode_addr_ts_bytes_array(result_hex: &str) -> Vec<AddrTsBytes> {
let raw = match hex_to_bytes(result_hex) {
Ok(b) => b,
Err(_) => return Vec::new(),
};
let read_usize = |off: usize| -> Option<usize> {
let end = off.checked_add(32)?;
let w = raw.get(off..end)?;
Some(u64::from_be_bytes(w[24..32].try_into().ok()?) as usize)
};
let mut out = Vec::new();
let arr_off = match read_usize(0) {
Some(o) => o,
None => return out,
};
let len = match read_usize(arr_off) {
Some(l) => l,
None => return out,
};
let heads = match arr_off.checked_add(32) {
Some(h) => h, None => return out,
};
for i in 0..len {
let head_slot = match i.checked_mul(32).and_then(|o| heads.checked_add(o)) {
Some(s) => s,
None => break,
};
let elem = match read_usize(head_slot) {
Some(rel) => match heads.checked_add(rel) {
Some(e) => e,
None => break,
},
None => break,
};
let addr = match elem
.checked_add(12)
.zip(elem.checked_add(32))
.and_then(|(a, b)| raw.get(a..b))
{
Some(a) => format!("0x{}", bytes_to_hex(a)),
None => break,
};
let ts = match elem
.checked_add(56)
.zip(elem.checked_add(64))
.and_then(|(a, b)| raw.get(a..b))
{
Some(t) => u64::from_be_bytes(t.try_into().unwrap_or_default()),
None => break,
};
let boff = match elem.checked_add(64).and_then(read_usize) {
Some(rel) => match elem.checked_add(rel) {
Some(b) => b,
None => break,
},
None => break,
};
let blen = match read_usize(boff) {
Some(l) => l,
None => break,
};
let bytes = boff
.checked_add(32)
.and_then(|start| start.checked_add(blen).map(|end| (start, end)))
.and_then(|(start, end)| raw.get(start..end))
.map(|s| s.to_vec())
.unwrap_or_default();
out.push((addr, ts, bytes));
}
out
}
pub async fn peers_of(topic: &[u8; 32]) -> Result<Vec<AddrTsBytes>, String> {
let mut data = selector("peersOf(bytes32)").to_vec();
data.extend_from_slice(topic);
let res = eth_call(REGISTRY_ADDRESS, &format!("0x{}", bytes_to_hex(&data))).await?;
Ok(decode_addr_ts_bytes_array(&res))
}
pub async fn inbox_of(peer: &[u8; 20], from_index: u64) -> Result<Vec<AddrTsBytes>, String> {
let mut data = selector("inboxOf(address,uint256)").to_vec();
data.extend_from_slice(&address_word(peer));
data.extend_from_slice(&u256_be(from_index as u128));
let res = eth_call(REGISTRY_ADDRESS, &format!("0x{}", bytes_to_hex(&data))).await?;
Ok(decode_addr_ts_bytes_array(&res))
}
pub async fn inbox_length(peer: &[u8; 20]) -> Result<u64, String> {
let mut data = selector("inboxLength(address)").to_vec();
data.extend_from_slice(&address_word(peer));
let res = eth_call(REGISTRY_ADDRESS, &format!("0x{}", bytes_to_hex(&data))).await?;
let raw = hex_to_bytes(&res)?;
if raw.len() < 32 {
return Ok(0);
}
Ok(u64::from_be_bytes(raw[24..32].try_into().map_err(|_| "bad len")?))
}
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)
}
async fn eth_gas_price() -> Result<u128, String> {
let hex = rpc("eth_gasPrice", serde_json::json!([])).await?;
parse_hex_quantity(&hex)
}
async fn eth_estimate_gas(from: &str, to: &str, data_hex: &str) -> Result<u128, String> {
let hex = rpc(
"eth_estimateGas",
serde_json::json!([{ "from": from, "to": to, "data": data_hex }]),
)
.await?;
let estimate = parse_hex_quantity(&hex)?;
Ok(estimate + estimate / 4)
}
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) => {
let lower = err.to_lowercase();
let is_duplicate = lower.contains("already known")
|| lower.contains("already exists")
|| lower.contains("nonce too low");
if is_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)))
} else {
Err(err)
}
}
}
}
fn decode_known_revert(selector_bytes: [u8; 4]) -> Option<&'static str> {
const KNOWN: &[(&str, &str)] = &[
("NotDue()", "this job isn't due yet — the scheduler only fires on the interval. Check `localharness jobs` for its next run."),
("StaleNextRun()", "this run was already fired by the scheduler — nothing to do (the on-chain clock already advanced)."),
("SpendExceedsBudget()", "the run would spend more $LH than the job's remaining budget — top it up or it will be marked exhausted."),
("NotScheduler()", "only the scheduler worker can record a run — this isn't a user action."),
("NotJobOwner()", "you don't own this job — only its scheduler can cancel/pause/top it up. Check `localharness jobs` under the right `--as` identity."),
("UnknownJob()", "no job with that id — list yours with `localharness jobs` (the id is the `#N`)."),
("JobNotActive()", "the job is already cancelled or exhausted — there's nothing to cancel. See `localharness jobs`."),
("JobNotPaused()", "the job isn't paused, so it can't be resumed."),
("UnregisteredTarget()", "the target isn't a registered agent — run `localharness whoami <target>` to confirm it exists first."),
("ZeroInterval()", "the interval is below the 60s minimum the facet allows — use `--every 60s` or more."),
("ZeroRuns()", "max-runs must be at least 1 — drop `--runs 0`."),
("CodeTaken()", "that invite code already exists on-chain — generate a fresh one (`invite create` makes a new code each time)."),
("BadTtl()", "the invite TTL is outside the allowed 1h..90d window — use e.g. `--ttl 7d`."),
("EscrowCapExceeded()", "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()", "no invite matches that code — double-check you copied the full code, including the `inv-` prefix."),
("NotOpen()", "this invite was already accepted or reclaimed — it's spent."),
("Expired()", "this invite has expired — it can no longer be accepted, only reclaimed by its funder (`invite reclaim <code>`)."),
("NotYetExpired()", "this invite hasn't expired yet — reclaim only works AFTER the TTL elapses. Until then it can still be accepted."),
("ZeroBudget()", "the budget/amount must be greater than 0."),
("ZeroAmount()", "the amount must be greater than 0."),
("NotConfigured()", "the on-chain credits token isn't configured — this is a platform-side misconfiguration, not your input. Report it via `localharness feedback`."),
("Error(string)", "the on-chain call reverted with a reason string (decoded above when available)."),
];
for (sig, msg) in KNOWN {
if selector(sig) == selector_bytes {
return Some(msg);
}
}
None
}
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 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!(
"{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(reason.to_string());
}
}
}
if sel == [0x4e, 0x48, 0x7b, 0x71] {
return Some("the contract hit an internal assertion (Panic) — this is a platform bug, not your input; please `localharness feedback` it.".to_string());
}
decode_known_revert(sel).map(|m| m.to_string())
}
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())
}
})
}
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.";
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}"))
}
#[cfg(not(target_arch = "wasm32"))]
pub async fn sleep_ms(ms: u32) {
tokio::time::sleep(std::time::Duration::from_millis(ms as u64)).await;
}
#[cfg(target_arch = "wasm32")]
pub async fn sleep_ms(ms: u32) {
use wasm_bindgen_futures::JsFuture;
let promise = js_sys::Promise::new(&mut |resolve, _| {
if let Some(window) = web_sys::window() {
let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(
&resolve,
ms as i32,
);
}
});
let _ = JsFuture::from(promise).await;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rank_agent_matches_filters_and_ranks() {
let agents = vec![
("alice".to_string(), "A friendly chatbot".to_string()),
("solidity-bob".to_string(), "general assistant".to_string()),
(
"carol".to_string(),
"An expert SOLIDITY auditor + security reviewer".to_string(),
),
("dave".to_string(), "writes haikus".to_string()),
];
let hits = rank_agent_matches(&agents, "solidity");
assert_eq!(hits.len(), 2);
assert_eq!(hits[0].0, "solidity-bob");
assert_eq!(hits[1].0, "carol");
assert!(rank_agent_matches(&agents, "nonexistent").is_empty());
assert_eq!(rank_agent_matches(&agents, "").len(), 4);
assert_eq!(rank_agent_matches(&agents, " ").len(), 4);
}
#[test]
fn decode_presence_signal_array() {
let hex = String::from("0x")
+ "0000000000000000000000000000000000000000000000000000000000000020" + "0000000000000000000000000000000000000000000000000000000000000001" + "0000000000000000000000000000000000000000000000000000000000000020" + "0000000000000000000000001111111111111111111111111111111111111111" + "0000000000000000000000000000000000000000000000000000000000000005" + "0000000000000000000000000000000000000000000000000000000000000060" + "0000000000000000000000000000000000000000000000000000000000000002" + "aabb000000000000000000000000000000000000000000000000000000000000"; let out = decode_addr_ts_bytes_array(&hex);
assert_eq!(out.len(), 1);
assert_eq!(out[0].0, "0x1111111111111111111111111111111111111111");
assert_eq!(out[0].1, 5);
assert_eq!(out[0].2, vec![0xAA, 0xBB]);
let empty = String::from("0x")
+ "0000000000000000000000000000000000000000000000000000000000000020"
+ "0000000000000000000000000000000000000000000000000000000000000000";
assert!(decode_addr_ts_bytes_array(&empty).is_empty());
}
#[test]
fn selector_matches_known_value() {
let sel = selector("idOfName(string)");
let hex: String = sel.iter().map(|b| format!("{b:02x}")).collect();
assert_eq!(hex, "127c388a");
}
#[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 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_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_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 encode_short_name_layout() {
let cd = encode_id_of_name("abc");
assert!(cd.starts_with("0x127c388a"));
assert_eq!(cd.len(), 2 + (4 + 32 + 32 + 32) * 2);
}
#[test]
fn invite_code_hash_matches_keccak256_of_code_bytes() {
let h_empty = invite_code_hash("");
let hex_empty: String = h_empty.iter().map(|b| format!("{b:02x}")).collect();
assert_eq!(
hex_empty,
"c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470"
);
let code = "inv-100-A8kZqM2pQr";
assert_eq!(invite_code_hash(code), keccak_key(code.as_bytes()));
assert_ne!(invite_code_hash("inv-10-aaaa"), invite_code_hash("inv-10-aaab"));
assert_eq!(invite_code_hash(code), invite_code_hash(code));
}
#[test]
fn encode_create_invite_layout() {
let code_hash = invite_code_hash("inv-100-deadbeef01");
let amount: u128 = 100 * 1_000_000_000_000_000_000; let ttl: u64 = 7 * 24 * 3600; let cd = encode_create_invite(&code_hash, amount, ttl);
assert_eq!(cd.len(), 4 + 3 * 32);
assert_eq!(&cd[..4], &selector("createInvite(bytes32,uint256,uint64)"));
assert_eq!(&cd[4..36], &code_hash[..]);
assert_eq!(&cd[36..68], &u256_be(amount)[..]);
assert_eq!(&cd[68..100], &u256_be(ttl as u128)[..]);
}
#[test]
fn encode_accept_invite_dynamic_string_layout() {
let code = "inv-1000-Qm2pZ8kXaa"; let cd = encode_accept_invite(code);
assert_eq!(&cd[..4], &selector("acceptInvite(string)"));
assert_eq!(&cd[4..36], &u256_be(0x20)[..]);
assert_eq!(&cd[36..68], &u256_be(code.len() as u128)[..]);
assert_eq!(&cd[68..68 + code.len()], code.as_bytes());
let padded = code.len().div_ceil(32) * 32;
assert_eq!(cd.len(), 4 + 32 + 32 + padded);
assert!(cd[68 + code.len()..].iter().all(|&b| b == 0));
assert_eq!(&cd[4..36], &encode_redeem(code)[4..36]);
assert_eq!(&cd[36..], &encode_redeem(code)[36..]);
}
#[test]
fn encode_reclaim_invite_layout() {
let code_hash = invite_code_hash("inv-10-cafef00d11");
let cd = encode_reclaim_invite(&code_hash);
assert_eq!(cd.len(), 4 + 32);
assert_eq!(&cd[..4], &selector("reclaimInvite(bytes32)"));
assert_eq!(&cd[4..36], &code_hash[..]);
}
#[test]
fn devices_topic_preimage_is_label_then_raw_address() {
let owner = "0x1111111111111111111111111111111111111111";
let topic = devices_topic(owner);
let mut pre = b"localharness.devices".to_vec();
pre.extend_from_slice(&parse_eth_address(owner).unwrap());
assert_eq!(topic, keccak_key(&pre));
assert_eq!(pre.len(), 40);
}
#[test]
fn announce_digest_is_packed_topic_ephemeral_pubkey() {
let topic = [0xABu8; 32];
let eph = [0x22u8; 20];
let pubkey = vec![0x02u8; 33];
let mut pre = Vec::new();
pre.extend_from_slice(&topic);
pre.extend_from_slice(&eph);
pre.extend_from_slice(&pubkey);
assert_eq!(pre.len(), 32 + 20 + 33);
assert_eq!(announce_digest(&topic, &eph, &pubkey), keccak32(&pre));
}
#[test]
fn announce_digest_signature_recovers_to_owner() {
let w = crate::wallet::generate();
let owner = crate::wallet::address(&w.signer); let topic = [0x11u8; 32];
let eph = [0x99u8; 20];
let pubkey = vec![0x03u8; 33];
let digest = announce_digest(&topic, &eph, &pubkey);
let sig = crate::wallet::sign_hash(&w.signer, &digest); let recovered = crate::wallet::recover_address(&sig, &digest)
.expect("sig recovers");
assert_eq!(recovered, owner, "announce sig recovers to the owner");
}
#[test]
fn encode_announce_5arg_layout() {
let topic = [0x11u8; 32];
let owner = [0x22u8; 20];
let eph = [0x33u8; 20];
let pubkey = vec![0x02u8; 33]; let sig = [0x44u8; 65];
let cd = encode_announce(&topic, &owner, &eph, &pubkey, &sig);
assert_eq!(
&cd[..4],
&selector("announce(bytes32,address,address,bytes,bytes)")
);
assert_eq!(&cd[4..36], &topic[..]);
assert_eq!(&cd[36..68], &address_word(&owner)[..]);
assert_eq!(&cd[68..100], &address_word(&eph)[..]);
assert_eq!(&cd[100..132], &u256_be(0xa0)[..]); assert_eq!(&cd[132..164], &u256_be(0x100)[..]); let pk_off = 4 + 0xa0;
assert_eq!(&cd[pk_off..pk_off + 32], &u256_be(33)[..]);
assert_eq!(&cd[pk_off + 32..pk_off + 32 + 33], &pubkey[..]);
let sig_off = 4 + 0x100;
assert_eq!(&cd[sig_off..sig_off + 32], &u256_be(65)[..]);
assert_eq!(&cd[sig_off + 32..sig_off + 32 + 65], &sig[..]);
assert_eq!(cd.len(), 4 + 5 * 32 + (32 + 64) + (32 + 96));
}
#[test]
fn proxy_auth_token_format_and_recovers_signer() {
let w = crate::wallet::generate();
let token = proxy_auth_token(&w.signer, 1_700_000_000);
let parts: Vec<&str> = token.split(':').collect();
assert_eq!(parts.len(), 3, "token is address:timestamp:signature");
let addr = format!("0x{}", bytes_to_hex(&crate::wallet::address(&w.signer)));
assert_eq!(parts[0], addr, "first field is the 0x address");
assert_eq!(parts[1], "1700000000", "second field is the unix timestamp");
assert!(parts[2].starts_with("0x"));
assert_eq!(parts[2].len(), 2 + 130, "signature is 65 bytes");
let msg = format!("localharness-proxy:{}:{}", parts[0], parts[1]);
let digest = crate::wallet::personal_sign_digest(msg.as_bytes());
let sig: [u8; 65] = hex_to_bytes(parts[2]).unwrap().try_into().unwrap();
let recovered = crate::wallet::recover_address(&sig, &digest).unwrap();
assert_eq!(format!("0x{}", bytes_to_hex(&recovered)), addr);
}
#[test]
fn encode_submit_feedback_abi_layout() {
let cd = encode_submit_feedback("hi");
assert_eq!(&cd[0..4], &selector("submitFeedback(string)"));
assert_eq!(&cd[4..36], &u256_be(0x20), "string offset");
assert_eq!(&cd[36..68], &u256_be(2), "string length");
assert_eq!(&cd[68..70], b"hi");
assert_eq!(cd.len(), 4 + 64 + 32, "selector + offset + len + padded payload");
assert_eq!(encode_submit_feedback(&"x".repeat(32)).len(), 4 + 64 + 32);
assert_eq!(encode_submit_feedback(&"x".repeat(33)).len(), 4 + 64 + 64);
}
#[test]
fn encode_set_persona_abi_layout() {
let cd = encode_set_persona(7, "hi");
assert_eq!(&cd[0..4], &selector("setMetadata(uint256,bytes32,bytes)"));
assert_eq!(&cd[4..36], &u256_be(7));
assert_eq!(&cd[36..68], &keccak_key(PERSONA_LABEL));
assert_eq!(&cd[68..100], &u256_be(0x60), "bytes offset");
assert_eq!(&cd[100..132], &u256_be(2), "payload length");
assert_eq!(&cd[132..134], b"hi");
assert_eq!(
cd.len(),
4 + 96 + 32 + 32,
"selector + 3 words + len + padded payload"
);
}
#[test]
fn encode_set_capability_commits_to_hash_not_payload() {
let payload = b"price=10;payee=0xabc;service=qa";
let cd = encode_set_capability(7, payload);
assert_eq!(&cd[0..4], &selector("setMetadata(uint256,bytes32,bytes)"));
assert_eq!(&cd[4..36], &u256_be(7));
assert_eq!(&cd[36..68], &keccak_key(CAPABILITY_LABEL));
assert_eq!(&cd[68..100], &u256_be(0x60), "bytes offset");
assert_eq!(&cd[100..132], &u256_be(32), "commitment is 32 bytes");
assert_eq!(&cd[132..164], &keccak_key(payload));
assert_ne!(&cd[132..164], &payload[..32.min(payload.len())]);
assert_eq!(cd.len(), 4 + 96 + 32 + 32);
}
#[test]
fn capability_key_distinct_from_other_metadata_keys() {
let cap = keccak_key(CAPABILITY_LABEL);
assert_ne!(cap, keccak_key(PERSONA_LABEL));
assert_ne!(cap, keccak_key(PUBLIC_FACE_LABEL));
assert_ne!(cap, keccak_key(PUBLIC_HTML_LABEL));
assert_ne!(cap, app_metadata_key());
}
#[test]
fn persona_key_distinct_from_other_metadata_keys() {
let persona = keccak_key(PERSONA_LABEL);
assert_ne!(persona, keccak_key(PUBLIC_FACE_LABEL));
assert_ne!(persona, keccak_key(PUBLIC_HTML_LABEL));
assert_ne!(persona, app_metadata_key());
}
#[test]
fn decode_zero_means_available() {
let z = format!("0x{}", "0".repeat(64));
assert_eq!(decode_u256_as_u64(&z).unwrap(), 0);
}
#[test]
fn decode_normal_id() {
let mut s = "0".repeat(63);
s.push('7');
let hex = format!("0x{s}");
assert_eq!(decode_u256_as_u64(&hex).unwrap(), 7);
}
#[test]
fn decode_oversize_errors() {
let mut s = String::from("1");
s.push_str(&"0".repeat(63));
let hex = format!("0x{s}");
assert!(decode_u256_as_u64(&hex).is_err());
}
const Z: &str = "0000000000000000000000000000000000000000000000000000000000000000";
fn word_usize(v: u64) -> String {
format!("{:064x}", v)
}
fn word_u64_max() -> String {
format!("{:048x}{:016x}", 0u64, u64::MAX)
}
#[test]
fn addr_ts_bytes_array_empty_and_short_inputs() {
assert!(decode_addr_ts_bytes_array("0x").is_empty());
assert!(decode_addr_ts_bytes_array("0x00").is_empty());
assert!(decode_addr_ts_bytes_array("0xabc").is_empty());
assert!(decode_addr_ts_bytes_array("0xzz").is_empty());
assert!(decode_addr_ts_bytes_array("nonsense").is_empty());
let off_oob = format!("0x{}", word_usize(0x40)); assert!(decode_addr_ts_bytes_array(&off_oob).is_empty());
}
#[test]
fn addr_ts_bytes_array_hostile_offsets_dont_overflow() {
let huge_off = format!("0x{}", word_u64_max());
assert!(decode_addr_ts_bytes_array(&huge_off).is_empty());
let huge_len = format!("0x{}{}", word_usize(0x20), word_u64_max());
assert!(decode_addr_ts_bytes_array(&huge_len).is_empty());
let bad_head = String::from("0x")
+ &word_usize(0x20) + &word_usize(1) + &word_u64_max(); assert!(decode_addr_ts_bytes_array(&bad_head).is_empty());
let bad_bytes_off = String::from("0x")
+ &word_usize(0x20) + &word_usize(1) + &word_usize(0x20) + &word_usize(0x1111) + &word_usize(7) + &word_u64_max(); assert!(decode_addr_ts_bytes_array(&bad_bytes_off).is_empty());
}
#[test]
fn addr_ts_bytes_array_multi_element_decodes() {
let elem0 = String::from("")
+ "0000000000000000000000001111111111111111111111111111111111111111" + &word_usize(1) + &word_usize(0x60) + &word_usize(1) + "aa00000000000000000000000000000000000000000000000000000000000000"; let elem1 = String::from("")
+ "0000000000000000000000002222222222222222222222222222222222222222"
+ &word_usize(2)
+ &word_usize(0x60)
+ &word_usize(2)
+ "bbcc000000000000000000000000000000000000000000000000000000000000";
let hex = String::from("0x")
+ &word_usize(0x20) + &word_usize(2) + &word_usize(0x40) + &word_usize(0xE0) + &elem0
+ &elem1;
let out = decode_addr_ts_bytes_array(&hex);
assert_eq!(out.len(), 2);
assert_eq!(out[0].0, "0x1111111111111111111111111111111111111111");
assert_eq!(out[0].1, 1);
assert_eq!(out[0].2, vec![0xAA]);
assert_eq!(out[1].0, "0x2222222222222222222222222222222222222222");
assert_eq!(out[1].1, 2);
assert_eq!(out[1].2, vec![0xBB, 0xCC]);
}
#[test]
fn metadata_bytes_edge_cases() {
assert_eq!(decode_metadata_bytes("0x"), None);
assert_eq!(decode_metadata_bytes(&format!("0x{Z}")), None);
let zero_len = format!("0x{}{}", word_usize(0x20), Z);
assert_eq!(decode_metadata_bytes(&zero_len), None);
let huge = format!("0x{}{}", word_usize(0x20), word_u64_max());
assert_eq!(decode_metadata_bytes(&huge), None);
let trunc = format!("0x{}{}", word_usize(0x20), word_usize(10)); assert_eq!(decode_metadata_bytes(&trunc), None);
let ok = format!(
"0x{}{}{}",
word_usize(0x20),
word_usize(3),
"aabbcc0000000000000000000000000000000000000000000000000000000000"
);
assert_eq!(decode_metadata_bytes(&ok), Some(vec![0xAA, 0xBB, 0xCC]));
}
#[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_feedback_data_edge_cases() {
assert!(decode_feedback_data(&[], "s".into()).is_none());
assert!(decode_feedback_data(&[0u8; 95], "s".into()).is_none());
let mut buf = vec![0u8; 96];
buf[88..96].copy_from_slice(&u64::MAX.to_be_bytes());
assert!(decode_feedback_data(&buf, "s".into()).is_none());
let body = String::from("")
+ &word_usize(9) + &word_usize(0x40) + &word_usize(2) + "6162000000000000000000000000000000000000000000000000000000000000";
let bytes = hex_to_bytes(&body).unwrap();
let entry = decode_feedback_data(&bytes, "sender".into()).unwrap();
assert_eq!(entry.timestamp, 9);
assert_eq!(entry.text, "ab");
assert_eq!(entry.sender, "sender");
}
#[test]
fn hex_to_bytes_rejects_malformed_without_panic() {
assert!(hex_to_bytes("0xabc").is_err()); assert!(hex_to_bytes("0xzz").is_err()); assert!(hex_to_bytes("0x").unwrap().is_empty()); assert_eq!(hex_to_bytes("0xAaBb").unwrap(), vec![0xAA, 0xBB]); assert_eq!(hex_to_bytes("deadbeef").unwrap(), vec![0xDE, 0xAD, 0xBE, 0xEF]); }
#[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")
);
}
#[test]
fn decode_u256_as_u128_truncation_and_empty() {
assert_eq!(decode_u256_as_u128("0x").unwrap(), 0);
assert_eq!(decode_u256_as_u128(&format!("0x{}", word_usize(42))).unwrap(), 42);
let max = format!("0x{}{}", "0".repeat(32), "f".repeat(32));
assert_eq!(decode_u256_as_u128(&max).unwrap(), u128::MAX);
}
}