@muhaven/mcp 0.2.9 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,78 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.0] — 2026-05-23
11
+
12
+ ### Added
13
+
14
+ - **Wave 5 Option D Commit 3 — MCP-side MODE.ENABLE UserOp pipeline.**
15
+ Closes the `paymaster_rejected → AA23 reverted 0x` smoke gap by
16
+ installing the PermissionValidator atomically with the first Path D
17
+ buy. On a freshly-minted Scoped session (`enable_status='pending'`
18
+ on the backend mirror), `position.buy` now:
19
+ - Fetches install material (`enableData` + `enableSig` +
20
+ `validatorNonce`) from the backend's
21
+ `GET /agent/policy/scoped-session/:id/install-material` subroute,
22
+ gated by `BROKER_CALLBACK_SERVICE_SECRET`.
23
+ - Calls the broker daemon's NEW `current_nonce` IPC verb to read the
24
+ kernel's live `currentNonce()` and pre-checks it against the
25
+ stored `validatorNonce`; mismatch surfaces as fallback
26
+ `enable_sig_stale` with a re-mint remediation.
27
+ - Composes the UserOp with `composeKernelV3NonceKey({mode:'enable'})`
28
+ (byte 0 of the 24-byte composite flips `0x00` → `0x01`) AND
29
+ wraps the 66-byte session-key signature with NEW
30
+ `wrapEnableModeSignature(...)` — a byte-exact mirror of
31
+ `@zerodev/sdk::getEncodedPluginsData`. The byte-equality is
32
+ pinned by 5 regression fixtures importing the canonical SDK as a
33
+ `devDep` (test-only — `@zerodev/sdk` is NOT in the runtime
34
+ bundle).
35
+ - After receipt, calls the broker daemon's NEW
36
+ `notify_userop_landed` IPC verb so the broker can POST the
37
+ backend's `validator-enabled` callback route. The chain indexer
38
+ is the authoritative source-of-truth; the callback is a fast-path
39
+ optimization.
40
+ - **Broker protocol bump 0.4.0 → 0.5.0.** Additive surface only —
41
+ legacy 0.4.0 callers continue to work. New verbs: `current_nonce`,
42
+ `notify_userop_landed`. New optional `enableData`/`enableSig`/
43
+ `validatorNonce` on `PolicySnapshotWire` with an all-or-none
44
+ refinement. New error codes: `chain_rpc_failed`, `callback_unconfigured`.
45
+ - **Broker daemon outbound egress (narrow, operator-approved
46
+ threat-model relaxation).** Until C3 the broker had ZERO outbound
47
+ channels; C3 adds exactly TWO via the NEW `BrokerOutbound` module:
48
+ - Chain RPC `eth_call` to `MUHAVEN_BROKER_RPC_URL` (fallback
49
+ `MUHAVEN_BUNDLER_URL`) for `kernel.currentNonce()` reads.
50
+ - HTTPS POST to backend's `validator-enabled` route with
51
+ `BROKER_CALLBACK_SERVICE_SECRET` bearer, exponential 5s/15s/60s/5m
52
+ backoff (`MUHAVEN_BROKER_ORIGIN` header per the ZeroDev
53
+ allowlist gotcha codified in earlier commits).
54
+ - Per-(sessionId, txHash, accountAddress) in-process dedup folds
55
+ flood IPC into a single retry loop.
56
+ - New fallback codes on `position.buy` Path D probe:
57
+ `install_material_unavailable`, `install_material_malformed`,
58
+ `enable_sig_stale`, `validator_install_failed_re_walk_required`,
59
+ `broker_chain_rpc_failed`.
60
+ - New broker config knobs: `MUHAVEN_BROKER_RPC_URL`,
61
+ `BROKER_CALLBACK_SERVICE_SECRET`, `MUHAVEN_BROKER_ORIGIN`.
62
+
63
+ ### Changed
64
+
65
+ - `composeKernelV3NonceKey` now accepts a `mode: 'default'|'enable'`
66
+ parameter. Default-omitted = `'default'` (backwards-compatible
67
+ with 0.2.x callers).
68
+ - `BackendClient` gains a `getServiceSecret(path, secret, query?)`
69
+ method (refactored `exchange` to share `runFetch`). Used only by
70
+ the install-material subroute.
71
+ - `daemon.ts` JSDoc header rewrites the "zero-egress" invariant to
72
+ document the C3 narrow outbound channels load-bearingly.
73
+
74
+ ### Notes
75
+
76
+ - 28 files changed, +4029 / -20 LOC.
77
+ - 18 new unit tests (5 byte-equality fixtures + 8 use-case + 6
78
+ watchdog + 6 indexer + 12 protocol parser + 5 daemon).
79
+ - @muhaven/mcp 0.3.0 publish requires `npm publish` after
80
+ `pnpm clean && pnpm build && pnpm test`.
81
+
10
82
  ## [0.2.9] — 2026-05-23
11
83
 
12
84
  ### Added
package/dist/broker.cjs CHANGED
@@ -7,6 +7,7 @@ var net = require('net');
7
7
  var promises = require('fs/promises');
8
8
  var accounts = require('viem/accounts');
9
9
  var crypto = require('crypto');
10
+ var viem = require('viem');
10
11
 
11
12
  var DEFAULT_BACKEND_URL = "https://api.muhaven.app";
12
13
  var DEFAULT_DASHBOARD_URL = "https://muhaven.app";
@@ -161,22 +162,45 @@ function loadBrokerConfig(env = process.env) {
161
162
  env.MUHAVEN_DASHBOARD_URL,
162
163
  DEFAULT_DASHBOARD_URL
163
164
  );
165
+ const chainRpcUrlRaw = readEnv("MUHAVEN_BROKER_RPC_URL", env) ?? readEnv("MUHAVEN_BUNDLER_URL", env);
166
+ const chainRpcUrl = chainRpcUrlRaw === void 0 ? void 0 : resolvePublicUrlEnv(
167
+ "MUHAVEN_BROKER_RPC_URL",
168
+ chainRpcUrlRaw,
169
+ chainRpcUrlRaw
170
+ );
171
+ const callbackServiceSecretRaw = readEnv("BROKER_CALLBACK_SERVICE_SECRET", env);
172
+ let callbackServiceSecret;
173
+ if (callbackServiceSecretRaw !== void 0) {
174
+ if (callbackServiceSecretRaw.length < 16) {
175
+ throw new Error(
176
+ "BROKER_CALLBACK_SERVICE_SECRET must be at least 16 characters (matches backend with-service-secret middleware floor)"
177
+ );
178
+ }
179
+ callbackServiceSecret = callbackServiceSecretRaw;
180
+ }
181
+ const outboundOriginHeader = readEnv("MUHAVEN_BROKER_ORIGIN", env) ?? dashboardBaseUrl;
164
182
  return {
165
183
  endpoint,
166
184
  sessionKeyHex,
167
185
  maxRequestBytes,
168
186
  requestTimeoutMs,
169
187
  backendBaseUrl,
170
- dashboardBaseUrl
188
+ dashboardBaseUrl,
189
+ chainRpcUrl,
190
+ callbackServiceSecret,
191
+ outboundOriginHeader
171
192
  };
172
193
  }
173
194
 
174
195
  // src/broker/protocol.ts
175
- var BROKER_PROTOCOL_VERSION = "0.4.0";
196
+ var BROKER_PROTOCOL_VERSION = "0.5.0";
176
197
  var HASH_HEX_RE = /^0x[0-9a-fA-F]{64}$/;
177
198
  var ADDRESS_HEX_RE2 = /^0x[0-9a-fA-F]{40}$/;
178
199
  var SELECTOR_HEX_RE = /^0x[0-9a-fA-F]{8}$/;
179
200
  var HEX_PREFIXED_RE = /^0x[0-9a-fA-F]*$/;
201
+ var ENABLE_DATA_HEX_RE = /^0x[0-9a-fA-F]{2,65536}$/;
202
+ var ENABLE_SIG_HEX_RE = /^0x[0-9a-fA-F]{256,16384}$/;
203
+ var UINT32_MAX = 4294967295;
180
204
  var JWT_RE = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/;
181
205
  var SESSION_ID_RE = /^[A-Za-z0-9_-]{1,128}$/;
182
206
  var UINT256_DEC_RE = /^(0|[1-9][0-9]{0,77})$/;
@@ -296,6 +320,43 @@ function parsePolicySnapshot(raw) {
296
320
  if (!isOptionalPermissionId(obj.permissionId)) {
297
321
  return { error: "snapshot.permissionId must be a 0x-prefixed 4-byte hex when provided" };
298
322
  }
323
+ const enableData = obj.enableData;
324
+ const enableSig = obj.enableSig;
325
+ const validatorNonce = obj.validatorNonce;
326
+ const installPresent = [enableData, enableSig, validatorNonce].filter(
327
+ (v) => v !== void 0
328
+ ).length;
329
+ if (installPresent !== 0 && installPresent !== 3) {
330
+ return {
331
+ error: "snapshot.{enableData,enableSig,validatorNonce} must be all-present or all-absent (Option D Commit 3 install-material trio)"
332
+ };
333
+ }
334
+ if (enableData !== void 0) {
335
+ if (typeof enableData !== "string" || !ENABLE_DATA_HEX_RE.test(enableData)) {
336
+ return {
337
+ error: "snapshot.enableData must be a 0x-prefixed hex string of 2..65536 chars when provided"
338
+ };
339
+ }
340
+ }
341
+ if (enableSig !== void 0) {
342
+ if (typeof enableSig !== "string" || !ENABLE_SIG_HEX_RE.test(enableSig)) {
343
+ return {
344
+ error: "snapshot.enableSig must be a 0x-prefixed hex string of 256..16384 chars (WebAuthn envelope) when provided"
345
+ };
346
+ }
347
+ }
348
+ if (validatorNonce !== void 0) {
349
+ if (typeof validatorNonce !== "number" || !Number.isInteger(validatorNonce) || validatorNonce < 0 || validatorNonce > UINT32_MAX) {
350
+ return {
351
+ error: "snapshot.validatorNonce must be an integer in [0, 2^32-1] when provided"
352
+ };
353
+ }
354
+ }
355
+ if (installPresent === 3 && obj.permissionId === void 0) {
356
+ return {
357
+ error: "snapshot install material requires permissionId in the same snapshot"
358
+ };
359
+ }
299
360
  return {
300
361
  sessionId: obj.sessionId,
301
362
  mode: "scoped",
@@ -308,7 +369,10 @@ function parsePolicySnapshot(raw) {
308
369
  mintedAtSec: obj.mintedAtSec,
309
370
  ...obj.consentActionHash === void 0 ? {} : { consentActionHash: obj.consentActionHash.toLowerCase() },
310
371
  ...obj.consentTextSha256 === void 0 ? {} : { consentTextSha256: obj.consentTextSha256.toLowerCase() },
311
- ...obj.permissionId === void 0 ? {} : { permissionId: obj.permissionId.toLowerCase() }
372
+ ...obj.permissionId === void 0 ? {} : { permissionId: obj.permissionId.toLowerCase() },
373
+ ...enableData === void 0 ? {} : { enableData: enableData.toLowerCase() },
374
+ ...enableSig === void 0 ? {} : { enableSig: enableSig.toLowerCase() },
375
+ ...validatorNonce === void 0 ? {} : { validatorNonce }
312
376
  };
313
377
  }
314
378
  function parseBrokerRequest(line) {
@@ -487,6 +551,72 @@ function parseBrokerRequest(line) {
487
551
  }
488
552
  case "get_active_session_id":
489
553
  return { type: "get_active_session_id" };
554
+ case "current_nonce": {
555
+ if (!isAddressHex(obj.accountAddress)) {
556
+ return {
557
+ type: "error",
558
+ code: "invalid_request",
559
+ message: "current_nonce.accountAddress must be a 0x-prefixed 20-byte hex"
560
+ };
561
+ }
562
+ return {
563
+ type: "current_nonce",
564
+ accountAddress: obj.accountAddress.toLowerCase()
565
+ };
566
+ }
567
+ case "notify_userop_landed": {
568
+ if (!isSessionIdShape(obj.sessionId)) {
569
+ return {
570
+ type: "error",
571
+ code: "invalid_request",
572
+ message: "notify_userop_landed.sessionId must be 1-128 chars [A-Za-z0-9_-]"
573
+ };
574
+ }
575
+ if (!isAddressHex(obj.accountAddress)) {
576
+ return {
577
+ type: "error",
578
+ code: "invalid_request",
579
+ message: "notify_userop_landed.accountAddress must be a 0x-prefixed 20-byte hex"
580
+ };
581
+ }
582
+ if (!isSelectorHex(obj.permissionId)) {
583
+ return {
584
+ type: "error",
585
+ code: "invalid_request",
586
+ message: "notify_userop_landed.permissionId must be a 0x-prefixed 4-byte hex"
587
+ };
588
+ }
589
+ if (!isHashHex(obj.txHash)) {
590
+ return {
591
+ type: "error",
592
+ code: "invalid_request",
593
+ message: "notify_userop_landed.txHash must be a 0x-prefixed 32-byte hex"
594
+ };
595
+ }
596
+ if (typeof obj.blockNumber !== "number" || !Number.isFinite(obj.blockNumber) || !Number.isInteger(obj.blockNumber) || obj.blockNumber < 0) {
597
+ return {
598
+ type: "error",
599
+ code: "invalid_request",
600
+ message: "notify_userop_landed.blockNumber must be a non-negative integer"
601
+ };
602
+ }
603
+ if (typeof obj.logIndex !== "number" || !Number.isFinite(obj.logIndex) || !Number.isInteger(obj.logIndex) || obj.logIndex < 0) {
604
+ return {
605
+ type: "error",
606
+ code: "invalid_request",
607
+ message: "notify_userop_landed.logIndex must be a non-negative integer"
608
+ };
609
+ }
610
+ return {
611
+ type: "notify_userop_landed",
612
+ sessionId: obj.sessionId,
613
+ accountAddress: obj.accountAddress.toLowerCase(),
614
+ permissionId: obj.permissionId.toLowerCase(),
615
+ txHash: obj.txHash.toLowerCase(),
616
+ blockNumber: obj.blockNumber,
617
+ logIndex: obj.logIndex
618
+ };
619
+ }
490
620
  default:
491
621
  return {
492
622
  type: "error",
@@ -646,6 +776,33 @@ var BrokerClient = class {
646
776
  }
647
777
  return res;
648
778
  }
779
+ // ── Wave 5 Option D Commit 3 — install-mode pre-check + callback notify ──
780
+ async currentNonce(accountAddress) {
781
+ const res = await this.exchange({ type: "current_nonce", accountAddress });
782
+ if (res.type !== "current_nonce") {
783
+ throw new BrokerClientError(
784
+ "protocol_error",
785
+ `expected current_nonce response, got ${res.type}`
786
+ );
787
+ }
788
+ if (res.accountAddress.toLowerCase() !== accountAddress.toLowerCase()) {
789
+ throw new BrokerClientError(
790
+ "protocol_error",
791
+ `current_nonce echo mismatch (requested ${accountAddress}, got ${res.accountAddress})`
792
+ );
793
+ }
794
+ return res;
795
+ }
796
+ async notifyUseropLanded(args) {
797
+ const res = await this.exchange({ type: "notify_userop_landed", ...args });
798
+ if (res.type !== "notify_userop_landed") {
799
+ throw new BrokerClientError(
800
+ "protocol_error",
801
+ `expected notify_userop_landed response, got ${res.type}`
802
+ );
803
+ }
804
+ return res;
805
+ }
649
806
  /**
650
807
  * Detect whether the running daemon speaks Path D (protocol 0.4.0+).
651
808
  * Wraps `hello()` with a semver-gte comparison so the MCP tool layer
@@ -1436,11 +1593,285 @@ function checkPolicy(input) {
1436
1593
  }
1437
1594
  return { ok: true };
1438
1595
  }
1596
+ var KERNEL_V3_CURRENT_NONCE_ABI = viem.parseAbi([
1597
+ "function currentNonce() view returns (uint32)"
1598
+ ]);
1599
+ var ChainRpcError = class extends Error {
1600
+ constructor(message, cause) {
1601
+ super(message);
1602
+ this.cause = cause;
1603
+ this.name = "ChainRpcError";
1604
+ }
1605
+ cause;
1606
+ };
1607
+ var CallbackError = class extends Error {
1608
+ constructor(message, cause) {
1609
+ super(message);
1610
+ this.cause = cause;
1611
+ this.name = "CallbackError";
1612
+ }
1613
+ cause;
1614
+ };
1615
+ var CALLBACK_RETRY_SCHEDULE_MS = [
1616
+ 5e3,
1617
+ 15e3,
1618
+ 6e4,
1619
+ 5 * 6e4
1620
+ ];
1621
+ var CALLBACK_MAX_ELAPSED_MS = 60 * 6e4;
1622
+ var DEFAULT_FETCH_TIMEOUT_MS = 15e3;
1623
+ var BrokerOutbound = class {
1624
+ constructor(config, log = () => {
1625
+ }) {
1626
+ this.config = config;
1627
+ this.log = log;
1628
+ this.fetchImpl = config.fetchImpl ?? fetch;
1629
+ this.fetchTimeoutMs = config.fetchTimeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
1630
+ this.setTimeoutImpl = config.setTimeout ?? setTimeout;
1631
+ this.clearTimeoutImpl = config.clearTimeout ?? clearTimeout;
1632
+ }
1633
+ config;
1634
+ log;
1635
+ fetchImpl;
1636
+ fetchTimeoutMs;
1637
+ setTimeoutImpl;
1638
+ clearTimeoutImpl;
1639
+ /**
1640
+ * Wave 5 Option D Commit 3 (multi-agent review SecEng-MED-3) —
1641
+ * in-process dedup of `notify_userop_landed` callbacks. Map of
1642
+ * `<sessionId>:<txHash>` → the in-flight retry loop's Promise.
1643
+ * Repeated IPC calls with the same key fold into the existing
1644
+ * loop instead of spawning a parallel POST. Defends against a
1645
+ * local-socket peer flooding the broker with replay attempts +
1646
+ * caps the retry-budget waste at one loop per real install.
1647
+ */
1648
+ inflightCallbacks = /* @__PURE__ */ new Map();
1649
+ /**
1650
+ * Read the kernel's `currentNonce()` view via `eth_call` against the
1651
+ * configured chain RPC. Returns a uint32. Throws `ChainRpcError` when
1652
+ * unconfigured / network failed / RPC returned non-decodable bytes.
1653
+ */
1654
+ async currentNonce(accountAddress) {
1655
+ if (!this.config.chainRpcUrl) {
1656
+ throw new ChainRpcError(
1657
+ "broker chain RPC unconfigured \u2014 set MUHAVEN_BROKER_RPC_URL or MUHAVEN_BUNDLER_URL"
1658
+ );
1659
+ }
1660
+ const data = viem.encodeFunctionData({
1661
+ abi: KERNEL_V3_CURRENT_NONCE_ABI,
1662
+ functionName: "currentNonce"
1663
+ });
1664
+ const body = JSON.stringify({
1665
+ jsonrpc: "2.0",
1666
+ id: 1,
1667
+ method: "eth_call",
1668
+ params: [{ to: accountAddress, data }, "latest"]
1669
+ });
1670
+ let res;
1671
+ const ac = new AbortController();
1672
+ const timer = this.setTimeoutImpl(() => ac.abort(), this.fetchTimeoutMs);
1673
+ try {
1674
+ res = await this.fetchImpl(this.config.chainRpcUrl, {
1675
+ method: "POST",
1676
+ headers: {
1677
+ "Content-Type": "application/json",
1678
+ Accept: "application/json",
1679
+ Origin: this.config.outboundOriginHeader
1680
+ },
1681
+ body,
1682
+ signal: ac.signal
1683
+ });
1684
+ } catch (err) {
1685
+ throw new ChainRpcError(
1686
+ `chain RPC fetch failed: ${err instanceof Error ? err.message : String(err)}`,
1687
+ err
1688
+ );
1689
+ } finally {
1690
+ this.clearTimeoutImpl(timer);
1691
+ }
1692
+ if (!res.ok) {
1693
+ throw new ChainRpcError(
1694
+ `chain RPC returned HTTP ${res.status}`
1695
+ );
1696
+ }
1697
+ let parsed;
1698
+ try {
1699
+ parsed = await res.json();
1700
+ } catch (err) {
1701
+ throw new ChainRpcError(
1702
+ `chain RPC returned non-JSON: ${err instanceof Error ? err.message : String(err)}`
1703
+ );
1704
+ }
1705
+ if (parsed.error) {
1706
+ throw new ChainRpcError(
1707
+ `chain RPC error: ${parsed.error.message ?? "unknown"}`
1708
+ );
1709
+ }
1710
+ if (typeof parsed.result !== "string" || !/^0x[0-9a-fA-F]*$/.test(parsed.result)) {
1711
+ throw new ChainRpcError(
1712
+ `chain RPC returned non-hex result: ${JSON.stringify(parsed.result).slice(0, 80)}`
1713
+ );
1714
+ }
1715
+ let nonce;
1716
+ try {
1717
+ const decoded = viem.decodeAbiParameters(
1718
+ [{ type: "uint32" }],
1719
+ parsed.result
1720
+ );
1721
+ nonce = Number(decoded[0]);
1722
+ } catch (err) {
1723
+ throw new ChainRpcError(
1724
+ `failed to decode currentNonce result: ${err instanceof Error ? err.message : String(err)}`
1725
+ );
1726
+ }
1727
+ if (!Number.isFinite(nonce) || nonce < 0 || nonce > 4294967295) {
1728
+ throw new ChainRpcError(`currentNonce out of uint32 range: ${nonce}`);
1729
+ }
1730
+ return nonce;
1731
+ }
1732
+ /**
1733
+ * Whether the callback path is wired (both secret + backend URL set).
1734
+ * The `notify_userop_landed` daemon handler checks this and returns
1735
+ * `callback_unconfigured` when false so the operator sees the gap.
1736
+ */
1737
+ isCallbackConfigured() {
1738
+ return Boolean(this.config.callbackServiceSecret) && Boolean(this.config.backendBaseUrl);
1739
+ }
1740
+ /**
1741
+ * Queue a `validator-enabled` callback POST to the backend. Returns
1742
+ * immediately; the retry loop runs in the background (5s / 15s / 60s
1743
+ * / 5m, max 1h elapsed). Failures are logged but do NOT propagate to
1744
+ * the IPC caller — the chain indexer is the authoritative safety
1745
+ * net.
1746
+ *
1747
+ * Idempotency: every POST carries an `Idempotency-Key` header
1748
+ * `<sessionId>:validator-enabled`. The backend route is no-op if the
1749
+ * mirror row's `enable_status` is already `'enabled'` (because the
1750
+ * chain indexer raced ahead).
1751
+ *
1752
+ * Returns a Promise resolved when the loop terminates (success or
1753
+ * max-elapsed). Callers don't need to await; tests use it for
1754
+ * deterministic assertions.
1755
+ */
1756
+ enqueueValidatorEnabledCallback(args) {
1757
+ if (!this.isCallbackConfigured()) {
1758
+ return Promise.resolve({
1759
+ ok: false,
1760
+ attempts: 0,
1761
+ lastError: "callback_unconfigured"
1762
+ });
1763
+ }
1764
+ const dedupKey = `${args.sessionId}:${args.txHash.toLowerCase()}:${args.accountAddress.toLowerCase()}`;
1765
+ const existing = this.inflightCallbacks.get(dedupKey);
1766
+ if (existing) {
1767
+ this.log("info", "validator-enabled callback already in flight \u2014 folded", {
1768
+ sessionId: args.sessionId
1769
+ });
1770
+ return existing;
1771
+ }
1772
+ const promise = this.runCallbackLoop(args).finally(() => {
1773
+ this.inflightCallbacks.delete(dedupKey);
1774
+ });
1775
+ this.inflightCallbacks.set(dedupKey, promise);
1776
+ return promise;
1777
+ }
1778
+ async runCallbackLoop(args) {
1779
+ const url = `${this.config.backendBaseUrl.replace(/\/+$/, "")}/api/v1/agent/policy/scoped-session/${encodeURIComponent(args.sessionId)}/validator-enabled`;
1780
+ const body = JSON.stringify({
1781
+ userId: args.userId,
1782
+ accountAddress: args.accountAddress,
1783
+ permissionId: args.permissionId,
1784
+ txHash: args.txHash,
1785
+ blockNumber: args.blockNumber,
1786
+ logIndex: args.logIndex
1787
+ });
1788
+ const startedAt = Date.now();
1789
+ let attempts = 0;
1790
+ let lastError;
1791
+ for (let i = 0; i <= CALLBACK_RETRY_SCHEDULE_MS.length; i++) {
1792
+ if (i > 0) {
1793
+ const delay = CALLBACK_RETRY_SCHEDULE_MS[i - 1] ?? 0;
1794
+ const elapsed = Date.now() - startedAt;
1795
+ if (elapsed + delay > CALLBACK_MAX_ELAPSED_MS) {
1796
+ lastError = `retry budget exhausted after ${attempts} attempts (last error: ${lastError ?? "unknown"})`;
1797
+ this.log("error", "validator-enabled callback abandoned", {
1798
+ sessionId: args.sessionId,
1799
+ attempts,
1800
+ lastError
1801
+ });
1802
+ return { ok: false, attempts, lastError };
1803
+ }
1804
+ await this.sleep(delay);
1805
+ }
1806
+ attempts++;
1807
+ try {
1808
+ const ok = await this.postCallback(url, body, args.sessionId);
1809
+ if (ok) {
1810
+ this.log("info", "validator-enabled callback succeeded", {
1811
+ sessionId: args.sessionId,
1812
+ attempts
1813
+ });
1814
+ return { ok: true, attempts };
1815
+ }
1816
+ lastError = "non-2xx response";
1817
+ } catch (err) {
1818
+ lastError = err instanceof Error ? err.message : String(err);
1819
+ this.log("warn", "validator-enabled callback attempt failed", {
1820
+ sessionId: args.sessionId,
1821
+ attempt: attempts,
1822
+ err: lastError
1823
+ });
1824
+ }
1825
+ }
1826
+ this.log("error", "validator-enabled callback retry budget exhausted", {
1827
+ sessionId: args.sessionId,
1828
+ attempts,
1829
+ lastError
1830
+ });
1831
+ return { ok: false, attempts, lastError };
1832
+ }
1833
+ async postCallback(url, body, sessionId) {
1834
+ const ac = new AbortController();
1835
+ const timer = this.setTimeoutImpl(() => ac.abort(), this.fetchTimeoutMs);
1836
+ let res;
1837
+ try {
1838
+ res = await this.fetchImpl(url, {
1839
+ method: "POST",
1840
+ headers: {
1841
+ "Content-Type": "application/json",
1842
+ Accept: "application/json",
1843
+ Authorization: `Bearer ${this.config.callbackServiceSecret}`,
1844
+ "Idempotency-Key": `${sessionId}:validator-enabled`,
1845
+ Origin: this.config.outboundOriginHeader
1846
+ },
1847
+ body,
1848
+ signal: ac.signal
1849
+ });
1850
+ } finally {
1851
+ this.clearTimeoutImpl(timer);
1852
+ }
1853
+ if (res.status === 409) {
1854
+ this.log("info", "callback returned 409 (row already enabled \u2014 idempotent)", {
1855
+ sessionId
1856
+ });
1857
+ return true;
1858
+ }
1859
+ if (res.ok) return true;
1860
+ throw new CallbackError(
1861
+ `backend callback returned HTTP ${res.status}`
1862
+ );
1863
+ }
1864
+ sleep(ms) {
1865
+ return new Promise((resolve) => {
1866
+ this.setTimeoutImpl(() => resolve(), ms);
1867
+ });
1868
+ }
1869
+ };
1439
1870
 
1440
1871
  // src/broker/daemon.ts
1441
1872
  var noopLogger = (_e) => {
1442
1873
  };
1443
- async function handleBrokerRequest(req, signer, keystore, nowSec = () => Math.floor(Date.now() / 1e3), options = {}, policyStore) {
1874
+ async function handleBrokerRequest(req, signer, keystore, nowSec = () => Math.floor(Date.now() / 1e3), options = {}, policyStore, outbound) {
1444
1875
  switch (req.type) {
1445
1876
  case "hello": {
1446
1877
  let hasJwt = false;
@@ -1648,6 +2079,57 @@ async function handleBrokerRequest(req, signer, keystore, nowSec = () => Math.fl
1648
2079
  );
1649
2080
  }
1650
2081
  }
2082
+ case "current_nonce": {
2083
+ if (!outbound) {
2084
+ return errorResponse(
2085
+ "chain_rpc_failed",
2086
+ "broker daemon was not configured with a chain RPC URL"
2087
+ );
2088
+ }
2089
+ try {
2090
+ const nonce = await outbound.currentNonce(req.accountAddress);
2091
+ return {
2092
+ type: "current_nonce",
2093
+ nonce,
2094
+ accountAddress: req.accountAddress
2095
+ };
2096
+ } catch (err) {
2097
+ if (err instanceof ChainRpcError) {
2098
+ return errorResponse("chain_rpc_failed", err.message);
2099
+ }
2100
+ return errorResponse(
2101
+ "chain_rpc_failed",
2102
+ err instanceof Error ? err.message : "chain RPC eth_call failed"
2103
+ );
2104
+ }
2105
+ }
2106
+ case "notify_userop_landed": {
2107
+ if (!outbound) {
2108
+ return errorResponse(
2109
+ "callback_unconfigured",
2110
+ "broker daemon was not configured with the callback service secret"
2111
+ );
2112
+ }
2113
+ if (!outbound.isCallbackConfigured()) {
2114
+ return errorResponse(
2115
+ "callback_unconfigured",
2116
+ "BROKER_CALLBACK_SERVICE_SECRET or backend URL is unset \u2014 validator-enabled callback skipped (chain indexer is the safety net)"
2117
+ );
2118
+ }
2119
+ void outbound.enqueueValidatorEnabledCallback({
2120
+ sessionId: req.sessionId,
2121
+ accountAddress: req.accountAddress,
2122
+ permissionId: req.permissionId,
2123
+ txHash: req.txHash,
2124
+ blockNumber: req.blockNumber,
2125
+ logIndex: req.logIndex
2126
+ });
2127
+ return {
2128
+ type: "notify_userop_landed",
2129
+ queued: true,
2130
+ sessionId: req.sessionId
2131
+ };
2132
+ }
1651
2133
  }
1652
2134
  }
1653
2135
  function errorResponse(code, message) {
@@ -1678,6 +2160,7 @@ var BrokerDaemon = class {
1678
2160
  config;
1679
2161
  keystore;
1680
2162
  policyStore;
2163
+ outbound;
1681
2164
  /**
1682
2165
  * Whether a session-key private half is actually loaded. `false` =
1683
2166
  * daemon booted in read-only posture (no `MUHAVEN_BROKER_SESSION_KEY`
@@ -1698,6 +2181,15 @@ var BrokerDaemon = class {
1698
2181
  }
1699
2182
  this.keystore = options.keystore ?? null;
1700
2183
  this.policyStore = options.policyStore ?? new FilePolicyStore(FilePolicyStore.defaultDir());
2184
+ this.outbound = options.outbound ?? new BrokerOutbound(
2185
+ {
2186
+ chainRpcUrl: options.config.chainRpcUrl,
2187
+ backendBaseUrl: options.config.backendBaseUrl,
2188
+ callbackServiceSecret: options.config.callbackServiceSecret,
2189
+ outboundOriginHeader: options.config.outboundOriginHeader ?? options.config.dashboardBaseUrl
2190
+ },
2191
+ (level, msg, meta) => (options.logger ?? noopLogger)({ level, msg, meta })
2192
+ );
1701
2193
  this.log = options.logger ?? noopLogger;
1702
2194
  this.server = net.createServer((socket) => this.onConnection(socket));
1703
2195
  }
@@ -1830,7 +2322,8 @@ var BrokerDaemon = class {
1830
2322
  },
1831
2323
  pid: process.pid
1832
2324
  },
1833
- this.policyStore
2325
+ this.policyStore,
2326
+ this.outbound
1834
2327
  );
1835
2328
  socket.end(serializeResponse(res));
1836
2329
  } catch (err) {
@@ -2783,7 +3276,7 @@ function printUsage() {
2783
3276
  }
2784
3277
  function getBrokerPackageVersion() {
2785
3278
  {
2786
- return "0.2.9";
3279
+ return "0.3.0";
2787
3280
  }
2788
3281
  }
2789
3282
  function printVersion() {