1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
//! Proxy-mediated paid agent call — the browser's route to a FOREIGN agent.
//!
//! The `?rpc=1` iframe path only reaches agents with state on THIS machine
//! (OPFS is per-origin but per-device), so an agent someone else owns can
//! never answer locally — it has no key and no persona here. This module is
//! the app half of the fallback `call_agent` uses instead: sign an x402
//! `PaymentAuthorization` paying the target's TBA in `$LH`, POST the
//! `ask_agent` tools/call to the hosted MCP endpoint (`<proxy>/mcp`), and
//! return the reply the proxy generated under the target's on-chain persona.
//! The caller's `$LH` pays; neither side needs a model key. Installed into
//! `x402_hook::install_remote_call` at mount.
use crate::registry;
// The auto-pay ceiling + the fallback-then-cap decision live in
// `registry::{REMOTE_CALL_MAX_AUTO_PAY_WEI, auto_pay_amount}` — pure and
// natively testable there (this module is wasm-gated). Only the error
// FORMATTING stays here.
/// How long to wait for the proxy's reply. The proxy settles on-chain and
/// then runs a full (non-streaming) model turn, so this is generous.
const REMOTE_CALL_TIMEOUT_MS: u32 = 120_000;
/// Exact-length address decode (20 bytes, optional 0x).
fn parse_addr(s: &str) -> Result<[u8; 20], String> {
let t = s.trim().trim_start_matches("0x");
if t.len() != 40 {
return Err(format!("bad address length: {s}"));
}
crate::encoding::hex_to_bytes(t)?
.try_into()
.map_err(|_| format!("bad address: {s}"))
}
/// Ask `target` through the hosted x402 endpoint, paying from the local
/// credit key. Returns the agent's reply text, or a descriptive error.
pub(crate) async fn ask_via_proxy(target: &str, message: &str) -> Result<String, String> {
// Reject an empty/whitespace message BEFORE signing any payment
// authorization — an empty ask used to ride all the way to the proxy,
// settle, then crash the runner with nothing to show for it (QA fleet
// #56/#119). No payment commitment for a blank prompt.
if message.trim().is_empty() {
return Err("message cannot be empty".to_string());
}
let (signer, from) = super::chat::credit_signer()
.await
.ok_or_else(|| "no identity to pay from".to_string())?;
let from_hex = crate::encoding::bytes_to_hex_str(&from);
// The payee is the target's on-chain TBA — resolved here AND re-checked
// by the proxy against the registry, so a bogus name fails fast.
let to_hex = registry::tba_of_name(target)
.await
.map_err(|e| format!("payee lookup: {e}"))?
.ok_or_else(|| format!("'{target}' is not a registered agent"))?;
let to = parse_addr(&to_hex)?;
// Pay the target's effective price (advertised on-chain, else the
// platform default) — the proxy enforces it as a floor, so paying the
// old flat tip would just 402. Capped by `registry::auto_pay_amount`.
let token_id = registry::id_of_name(target)
.await
.map_err(|e| format!("price lookup: {e}"))?;
let advertised = registry::x402_price_of(token_id)
.await
.map_err(|e| format!("price lookup: {e}"))?;
let pay_wei = registry::auto_pay_amount(
advertised,
registry::REMOTE_CALL_MAX_AUTO_PAY_WEI,
)
.map_err(|over_cap_wei| {
format!(
"'{target}' charges {} $LH per call — above the {} $LH auto-pay cap; \
call it yourself if you accept the price",
crate::app::format_wei_as_test_eth(over_cap_wei),
crate::app::format_wei_as_test_eth(registry::REMOTE_CALL_MAX_AUTO_PAY_WEI),
)
})?;
// Pre-flight the payer's WALLET balance. x402 `settle` pulls real $LH
// from the wallet pot via `transferFrom`, NOT from the chat meter
// (`creditOf`) — but unspent meter credits are the user's own escrow,
// so when the wallet is short the AUTO-BRIDGE pulls the shortfall back
// out via `withdrawCredits` (sponsored) and the call just proceeds.
// One balance in practice; the error below only fires when BOTH pots
// together can't cover the price. A read failure shouldn't hard-block —
// settle is the authoritative gate.
if let Ok(wallet_wei) = registry::token_balance_of(&from_hex).await {
if wallet_wei < pay_wei {
let shortfall = pay_wei - wallet_wei;
let meter_wei = registry::credit_balance_of(&from_hex).await.unwrap_or(0);
if meter_wei >= shortfall {
let sponsor = super::sponsor::signer()?;
registry::withdraw_credits_sponsored(
&signer,
&sponsor,
shortfall,
registry::ALPHA_USD_ADDRESS(),
)
.await
.map_err(|e| format!("credit withdraw (meter -> wallet): {e}"))?;
} else {
return Err(format!(
"calling '{target}' costs {} $LH but your wallet holds {} \
$LH and your chat meter {} $LH — fund up with a redeem \
code, an invite, or a $LH transfer, then retry",
crate::app::format_wei_as_test_eth(pay_wei),
crate::app::format_wei_as_test_eth(wallet_wei),
crate::app::format_wei_as_test_eth(meter_wei),
));
}
}
}
// `settle` pulls the $LH from the payer via `transferFrom`, so the payer
// must have approved the diamond once. Sponsored, so a fresh identity
// with zero gas can still approve.
match registry::lh_allowance(&from_hex, registry::REGISTRY_ADDRESS()).await {
Ok(allowance) if allowance >= pay_wei => {}
Ok(_) => {
let sponsor = super::sponsor::signer()?;
registry::approve_lh_sponsored(
&signer,
&sponsor,
registry::REGISTRY_ADDRESS(),
u128::MAX,
registry::ALPHA_USD_ADDRESS(),
)
.await
.map_err(|e| format!("$LH approve: {e}"))?;
}
Err(_) => {}
}
let now = (js_sys::Date::now() / 1000.0) as u64;
let valid_before = now + 3600;
let nonce = registry::random_x402_nonce();
let signature = registry::sign_x402(
&signer,
&from,
&to,
pay_wei,
0,
valid_before,
&nonce,
)?;
let header = registry::x402_authorization_json(
&from_hex,
&to_hex,
pay_wei,
0,
valid_before,
&nonce,
&signature,
);
let body = registry::x402_ask_agent_body(target, message);
// Browser fetch has no timeout (and `reqwest::Client::timeout` is a no-op
// on wasm) — race against a timer like `registry::rpc` does.
let json = super::net::with_timeout(REMOTE_CALL_TIMEOUT_MS, async {
let resp = reqwest::Client::new()
.post(registry::mcp_endpoint_url())
.header("content-type", "application/json")
.header("x-x402-authorization", header.to_string())
.json(&body)
.send()
.await
.map_err(|e| format!("proxy request: {e}"))?;
resp.json::<serde_json::Value>()
.await
.map_err(|e| format!("proxy response decode: {e}"))
})
.await
.map_err(|_| format!("proxy call timed out after {}s", REMOTE_CALL_TIMEOUT_MS / 1000))??;
registry::parse_mcp_tool_reply(&json)
}