Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 39 additions & 5 deletions crates/agentkeys-broker-server/src/handlers/accept.rs
Original file line number Diff line number Diff line change
Expand Up @@ -302,10 +302,13 @@ pub fn load_accept_config() -> Result<(AcceptConfig, SigningKey), String> {

// #278 D6: verificationGasLimit / preVerificationGas are env > profile > default
// (shared by accept / scope / revoke / register). The live RIP-7212 precompile
// (#170 / #288) makes the on-chain P-256 verify ~3.4k gas, so a precompile chain
// (Base) carries ~200k in its profile; chains still on the pure-Solidity verifier
// keep the 1.5M default. Lowering blind on a non-precompile chain reverts inside
// validateUserOp as a false SIG_VALIDATION_FAILED (#225 / gap #7).
// (#170 / #288) makes the on-chain P-256 verify ~3.4k gas — BUT the D6 register
// also DEPLOYS the P256Account inside the UserOp (initCode), and the EntryPoint
// runs that deploy within verificationGasLimit (~1.3M on Base). So the value must
// cover the DEPLOY, not just the verify: Base carries ~1.6M in its profile even
// with the cheap precompile (the earlier 200k OOG'd the deploy → AA13). Chains on
// the pure-Solidity verifier keep the 1.5M default. Lowering it below the real
// cost reverts inside validateUserOp as a false SIG_VALIDATION_FAILED (#225 / gap #7).
// Finalize chain_id: explicit env wins, else the compiled profile's id, else a
// loud-warned heima fallback (only reachable on a custom chain with no env + no
// built-in profile — exactly the case the old silent 212013 default mis-signed).
Expand All @@ -332,6 +335,22 @@ pub fn load_accept_config() -> Result<(AcceptConfig, SigningKey), String> {
prof_pvg,
DEF_PRE_VERIFICATION_GAS,
);
// gas_fees come from the chain's real fee market (its compiled profile, gwei→wei),
// with the broker DEF as the fallback for a chain with no usable profile fee
// (legacy/anvil maxFee=0). The old hardcoded 40 gwei is Heima's base-fee scale; on
// Base (~0.005 gwei base) it reserved a ~0.16 ETH UserOp prefund the paymaster
// deposit can't cover — a false AA31 the moment the AA13 deploy-gas was fixed.
let (max_priority_fee, max_fee) = resolved_profile
.as_ref()
.map(|p| (p.gas.max_priority_fee_gwei, p.gas.max_fee_gwei))
.filter(|&(_, max_fee_gwei)| max_fee_gwei > 0)
.map(|(prio_gwei, max_fee_gwei)| {
(
prio_gwei as u128 * 1_000_000_000,
max_fee_gwei as u128 * 1_000_000_000,
)
})
.unwrap_or((DEF_MAX_PRIORITY_FEE, DEF_MAX_FEE));

let cfg = AcceptConfig {
rpc_url,
Expand All @@ -346,7 +365,7 @@ pub fn load_accept_config() -> Result<(AcceptConfig, SigningKey), String> {
DEF_CALL_GAS_LIMIT,
),
pre_verification_gas: u256_word(pre_verification_gas_amt),
gas_fees: crate::sponsor::pack_u128_pair(DEF_MAX_PRIORITY_FEE, DEF_MAX_FEE),
gas_fees: crate::sponsor::pack_u128_pair(max_priority_fee, max_fee),
paymaster_verification_gas_limit: DEF_PAYMASTER_VERIFICATION_GAS,
paymaster_post_op_gas_limit: DEF_PAYMASTER_POST_OP_GAS,
};
Expand Down Expand Up @@ -796,6 +815,15 @@ pub async fn accept_submit(
)
.await
.map_err(|e| {
// Log on the broker host: the submit error was previously returned to the
// caller WITHOUT tracing, so a bundler rejection (e.g. a wrong-chain-id outer
// tx the RPC refuses) left an empty broker journal and a fast, silent 502.
tracing::warn!(
target: "agentkeys.broker.accept",
bundler = %bundler_url,
error = %e,
"submit relay: bundler eth_sendUserOperation failed — UserOp NOT broadcast"
);
aerr(
StatusCode::BAD_GATEWAY,
format!("handleOps did not broadcast: {e}"),
Expand Down Expand Up @@ -847,6 +875,12 @@ pub async fn accept_submit(
// paymaster deposit too low") — map it to operator guidance
// instead of guessing "wrong passkey".
let reason = receipt.get("reason").and_then(|r| r.as_str());
tracing::warn!(
target: "agentkeys.broker.accept",
tx = %tx_hash,
reason = reason.unwrap_or("(none)"),
"submit relay: handleOps reverted on-chain"
);
return Err(aerr(
StatusCode::BAD_GATEWAY,
handle_ops_revert_message(reason, &tx_hash),
Expand Down
2 changes: 1 addition & 1 deletion crates/agentkeys-bundler/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
//! ENTRYPOINT_ADDRESS[_HEIMA] = 0x… (absent ⇒ degraded)
//! AGENTKEYS_BUNDLER_SIGNER_KEY = 0x… (or BROKER_SPONSOR_SIGNER_KEY; absent ⇒ degraded)
//! Optional:
//! AGENTKEYS_CHAIN_ID[_HEIMA] (default 212013)
//! AGENTKEYS_CHAIN_ID[_<CHAIN>] (override; else the compiled chain profile for AGENTKEYS_CHAIN — no Heima default)
//! AGENTKEYS_HANDLEOPS_GAS_LIMIT (default 4000000 — Heima can't estimate handleOps)
//! AGENTKEYS_BUNDLER_GAS_PRICE (wei; default eth_gasPrice +25%)
//! AGENTKEYS_BUNDLER_BIND (default 127.0.0.1:9098 — loopback-only, PRIVATE)
Expand Down
69 changes: 65 additions & 4 deletions crates/agentkeys-bundler/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,21 @@ fn env_profile(base: &str) -> Result<String> {
.map_err(|_| anyhow!("env {base}[_{p}] not set"))
}

/// Chain id from the compiled-in chain profile for `AGENTKEYS_CHAIN` — the SAME
/// source of truth the broker resolves against. A host that sets only
/// `AGENTKEYS_CHAIN=base` therefore gets Base's `8453`, never the legacy Heima
/// default that silently made the bundler sign + broadcast `handleOps` for the
/// wrong chain (the Base register-never-broadcast bug). Returns the id as a
/// string so it flows through the same `BundlerBootValues.chain_id` field as an
/// explicit `AGENTKEYS_CHAIN_ID[_<CHAIN>]` override; `None` only for an unknown
/// chain with no compiled profile, which `from_values` then rejects (fail loud).
fn chain_id_from_chain_profile() -> Option<String> {
let chain = std::env::var("AGENTKEYS_CHAIN").unwrap_or_else(|_| "heima".into());
agentkeys_core::chain_profile::ChainProfile::load_builtin(&chain)
.ok()
.map(|p| p.chain_id.to_string())
}

fn addr20(hex_s: &str, name: &str) -> Result<[u8; 20]> {
hex::decode(hex_s.trim().trim_start_matches("0x"))
.map_err(|e| anyhow!("{name}: {e}"))?
Expand Down Expand Up @@ -77,7 +92,9 @@ pub enum BundlerBoot {
pub struct BundlerBootValues {
/// `AGENTKEYS_CHAIN_RPC_HTTP`.
pub rpc_url: Option<String>,
/// `AGENTKEYS_CHAIN_ID[_<CHAIN>]` (default 212013 when absent/unparseable).
/// `AGENTKEYS_CHAIN_ID[_<CHAIN>]` override, else the compiled chain profile
/// for `AGENTKEYS_CHAIN` (resolved in `from_process_env`). No Heima default —
/// an unresolved id makes `from_values` fail loud rather than mis-sign.
pub chain_id: Option<String>,
/// `ENTRYPOINT_ADDRESS[_<CHAIN>]`.
pub entry_point: Option<String>,
Expand All @@ -94,7 +111,11 @@ impl BundlerBootValues {
pub fn from_process_env() -> Self {
Self {
rpc_url: std::env::var("AGENTKEYS_CHAIN_RPC_HTTP").ok(),
chain_id: env_profile("AGENTKEYS_CHAIN_ID").ok(),
// Explicit override first (profileless chains / local dev), else the
// compiled chain profile for AGENTKEYS_CHAIN (SoT). No Heima default.
chain_id: env_profile("AGENTKEYS_CHAIN_ID")
.ok()
.or_else(chain_id_from_chain_profile),
entry_point: env_profile("ENTRYPOINT_ADDRESS").ok(),
signer_key: std::env::var("AGENTKEYS_BUNDLER_SIGNER_KEY")
.or_else(|_| std::env::var("BROKER_SPONSOR_SIGNER_KEY"))
Expand All @@ -120,10 +141,23 @@ impl BundlerBoot {
/// config, always pinned in the systemd unit).
pub fn from_values(values: BundlerBootValues) -> Result<Self> {
let rpc_url = values.rpc_url.context("env AGENTKEYS_CHAIN_RPC_HTTP")?;
// No silent default. The legacy `unwrap_or(212_013)` made a Base (or any
// non-Heima) host quietly sign + broadcast `handleOps` for Heima's chain
// id, which the target RPC rejects — the Base master-register UserOp never
// broadcast (submitter nonce stayed 0; a fast, silent 502). `from_process_env`
// fills this from an explicit AGENTKEYS_CHAIN_ID[_<CHAIN>] override or the
// compiled chain profile; an unresolved/garbled id is a hard misconfig.
let chain_id: u64 = values
.chain_id
.and_then(|s| s.parse().ok())
.unwrap_or(212_013);
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.context(
"bundler chain id unresolved — set AGENTKEYS_CHAIN_ID[_<CHAIN>] or run with an \
AGENTKEYS_CHAIN that has a compiled chain profile; refusing to assume Heima (212013)",
)?
.parse()
.context("AGENTKEYS_CHAIN_ID must be a u64 chain id")?;
let mut missing = Vec::new();
let entry_point = match values.entry_point.filter(|s| !s.trim().is_empty()) {
Some(s) => Some(addr20(&s, "ENTRYPOINT_ADDRESS")?),
Expand Down Expand Up @@ -739,6 +773,10 @@ mod tests {
rpc_url: rpc.map(str::to_string),
entry_point: entry_point.map(str::to_string),
signer_key: signer_key.map(str::to_string),
// chain id is resolved (override or compiled profile) BEFORE from_values;
// pin Heima's here so these tests exercise EntryPoint/key branching, not
// chain-id resolution.
chain_id: Some("212013".into()),
..Default::default()
}
}
Expand Down Expand Up @@ -813,4 +851,27 @@ mod tests {
BundlerBoot::Degraded { missing, .. } => panic!("expected Ready, missing={missing:?}"),
}
}

#[test]
fn from_values_rejects_unresolved_chain_id() {
// The old code silently defaulted a missing chain id to Heima's 212013, so a
// Base host signed handleOps for the wrong chain. Now unresolved ⇒ hard error.
let key = format!("0x{}", "46".repeat(32));
let ep = format!("0x{}", "66".repeat(20));
let mut v = boot_values(Some("http://127.0.0.1:1"), Some(&ep), Some(&key));
v.chain_id = None;
assert!(BundlerBoot::from_values(v).is_err());
}

#[test]
fn compiled_profiles_pin_base_and_heima_chain_ids() {
use agentkeys_core::chain_profile::ChainProfile;
// The SoT the bundler now resolves against: a Base host MUST sign for 8453,
// a Heima host for 212013 — never a hardcoded default.
assert_eq!(ChainProfile::load_builtin("base").unwrap().chain_id, 8453);
assert_eq!(
ChainProfile::load_builtin("heima").unwrap().chain_id,
212_013
);
}
}
10 changes: 5 additions & 5 deletions crates/agentkeys-core/chain-profiles/base.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"chain_id": 8453,
"chain_kind": "optimism-l2",
"rpc": {
"http": "https://mainnet.base.org",
"http": "https://base-rpc.publicnode.com",
"wss": "wss://base-rpc.publicnode.com"
},
"explorer": {
Expand All @@ -25,8 +25,8 @@
"gas": {
"model": "eip1559",
"max_priority_fee_gwei": 1,
"max_fee_gwei": 50,
"verification_gas_limit": 200000
"max_fee_gwei": 1,
"verification_gas_limit": 1600000
},
"deploy": {
"deployer_env_var": "AGENTKEYS_BASE_DEPLOYER_KEY",
Expand Down Expand Up @@ -95,8 +95,8 @@
],
"funding": {
"deploy_min_wei": "3000000000000000",
"paymaster_deposit_wei": "1000000000000000",
"paymaster_min_deposit_wei": "1000000000000000",
"paymaster_deposit_wei": "20000000000000000",
"paymaster_min_deposit_wei": "8000000000000000",
"ed_buffer_wei": "0",
"account_deposit_wei": "5000000000000000",
"userop_max_fee_wei": "1000000000"
Expand Down
2 changes: 1 addition & 1 deletion crates/agentkeys-core/chain-profiles/heima.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
"gas": {
"model": "eip1559",
"max_priority_fee_gwei": 1,
"max_fee_gwei": 1000
"max_fee_gwei": 40
},
"deploy": {
"deployer_env_var": "AGENTKEYS_HEIMA_DEPLOYER_KEY",
Expand Down
35 changes: 22 additions & 13 deletions crates/agentkeys-core/src/chain_profile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -260,16 +260,25 @@ pub struct FinalityConfig {
pub struct GasConfig {
/// `"eip1559"` or `"legacy"`. Anvil + some local dev chains use legacy.
pub model: String,
/// `maxPriorityFeePerGas` (gwei) for master UserOps. Wired into the op's
/// gas_fees by the broker (gwei→wei), falling back to the broker default when a
/// profile has no usable fee (legacy/anvil `max_fee_gwei == 0`).
pub max_priority_fee_gwei: u64,
/// `maxFeePerGas` (gwei) for master UserOps — the chain's real fee ceiling, NOT
/// a blanket value. It sizes the EntryPoint prefund reserve (× total gas), so on
/// a cheap L2 (Base ~0.005 gwei base fee → ~2 here) a Heima-scale 40+ gwei would
/// reserve a prefund the paymaster deposit can't cover (false AA31).
pub max_fee_gwei: u64,
/// ERC-4337 UserOp `verificationGasLimit` for master ops (accept / scope /
/// revoke / #278 D6 register). The account's `validateUserOp` runs an
/// on-chain P-256 verify; on a chain with the live RIP-7212 precompile
/// (#170 / #288) that is ~3.4k gas, so precompile chains set ~200k here while
/// chains still on the pure-Solidity verifier keep the broker's 1.5M default.
/// `None` ⇒ the broker default. NEVER set it below the real verify cost:
/// under-gas reverts inside `validateUserOp` as a false `SIG_VALIDATION_FAILED`
/// (#225 / gap #7). Env `ACCEPT_VERIFICATION_GAS_LIMIT[_<CHAIN>]` overrides it.
/// revoke / #278 D6 register). `validateUserOp` runs an on-chain P-256 verify
/// (~3.4k gas on a RIP-7212 precompile chain, #170 / #288) — BUT the D6 register
/// also deploys the P256Account inside the UserOp (initCode), which the EntryPoint
/// runs within this limit (~1.3M on Base). So set it to cover the DEPLOY, not just
/// the verify (Base: 1.6M even with the precompile; the earlier 200k OOG'd the
/// deploy → AA13). `None` ⇒ the broker's 1.5M default (chains on the pure-Solidity
/// verifier). NEVER set it below the real cost: under-gas reverts inside
/// `validateUserOp` as a false `SIG_VALIDATION_FAILED` (#225 / gap #7).
/// Env `ACCEPT_VERIFICATION_GAS_LIMIT[_<CHAIN>]` overrides it.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub verification_gas_limit: Option<u128>,
/// ERC-4337 UserOp `preVerificationGas` for master ops. `None` ⇒ the broker
Expand Down Expand Up @@ -551,13 +560,13 @@ mod tests {

#[test]
fn base_gas_carries_precompile_verification_limit_heima_defaults() {
// #278 D6: Base has the live RIP-7212 precompile (#287), so its profile
// pins the cheap ~200k verificationGasLimit; Heima stays on the broker's
// 1.5M default (None here) until the register verify is profiled live
// with the precompile — lowering it blind would revert as a false
// SIG_VALIDATION_FAILED (#225).
// #278 D6: the register DEPLOYS the P256Account inside the UserOp (initCode),
// and the EntryPoint runs that ~1.3M deploy within verificationGasLimit — so
// even with Base's cheap RIP-7212 precompile verify (#287) the value must cover
// the DEPLOY (1.6M here; the earlier 200k OOG'd it → AA13). Heima stays on the
// broker's 1.5M default (None here).
let base = ChainProfile::load_builtin("base").unwrap();
assert_eq!(base.gas.verification_gas_limit, Some(200_000));
assert_eq!(base.gas.verification_gas_limit, Some(1_600_000));
let heima = ChainProfile::load_builtin("heima").unwrap();
assert_eq!(heima.gas.verification_gas_limit, None);
assert_eq!(heima.gas.pre_verification_gas, None);
Expand Down
37 changes: 25 additions & 12 deletions crates/agentkeys-daemon/src/ui_bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2514,14 +2514,25 @@ async fn master_register_submit(
let (resp, parsed) =
forward_to_broker_value(&broker, "/v1/register/submit", &j1, &body).await;
if !resp.status().is_success() {
let detail = parsed
.map(|v| v.to_string())
.unwrap_or_else(|| "broker /v1/register/submit failed".into());
return Err(err(
StatusCode::BAD_GATEWAY,
detail,
"register-submit-failed",
));
// Surface the broker's REAL reason — it emits a precise error (e.g.
// "handleOps did not broadcast: bundler eth_sendUserOperation: <reason>"
// or a decoded handleOps revert). The old generic fallback hid every
// Base register failure behind one opaque line. Forward the broker's
// status too, so a 409 already-registered / 503 sponsored-only doesn't
// masquerade as a 502 gateway error.
let status = resp.status();
let detail = parsed.map(|v| v.to_string()).unwrap_or_else(|| {
"broker /v1/register/submit failed (broker returned an empty or non-JSON body)"
.into()
});
tracing::warn!(
target: "agentkeys.daemon.ui_bridge",
account = %pending.account,
%status,
detail = %detail,
"#278 D6: master register submit FAILED at broker — UserOp not broadcast"
);
return Err(err(status, detail, "register-submit-failed"));
}
let v = parsed.unwrap_or(serde_json::Value::Null);
let (tx_hash, _) = submit_receipts(&v);
Expand Down Expand Up @@ -4703,10 +4714,12 @@ async fn forward_to_broker_value(
let st =
StatusCode::from_u16(resp.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY);
let txt = resp.text().await.unwrap_or_default();
let parsed = st
.is_success()
.then(|| serde_json::from_str::<serde_json::Value>(&txt).ok())
.flatten();
// Parse the body on BOTH success and error. Success callers read the
// submit receipts from it; the #278 register-submit error path surfaces
// the broker's REAL reason from it (e.g. "handleOps did not broadcast:
// bundler eth_sendUserOperation: <reason>") instead of a generic
// fallback — propagate the error through every layer, never swallow it.
let parsed = serde_json::from_str::<serde_json::Value>(&txt).ok();
let response = (
st,
[(axum::http::header::CONTENT_TYPE, "application/json")],
Expand Down
Loading
Loading