@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 +72 -0
- package/dist/broker.cjs +499 -6
- package/dist/broker.js +499 -6
- package/dist/index.cjs +735 -14
- package/dist/index.d.cts +301 -14
- package/dist/index.d.ts +301 -14
- package/dist/index.js +736 -15
- package/manifest.json +1 -1
- package/package.json +2 -1
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.
|
|
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.
|
|
3279
|
+
return "0.3.0";
|
|
2787
3280
|
}
|
|
2788
3281
|
}
|
|
2789
3282
|
function printVersion() {
|