@toon-protocol/townhouse 0.1.0-rc5 → 0.1.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/README.md +117 -0
- package/dist/{chunk-IB6TNCUQ.js → chunk-4WCMVIO4.js} +3922 -473
- package/dist/chunk-4WCMVIO4.js.map +1 -0
- package/dist/chunk-GQNBZJ6F.js +39 -0
- package/dist/chunk-GQNBZJ6F.js.map +1 -0
- package/dist/{chunk-UTFWPLTB.js → chunk-I2R4CRUX.js} +2 -22
- package/dist/chunk-I2R4CRUX.js.map +1 -0
- package/dist/chunk-JCOFMUPL.js +65 -0
- package/dist/chunk-JCOFMUPL.js.map +1 -0
- package/dist/cli.d.ts +94 -2
- package/dist/cli.js +3115 -111
- package/dist/cli.js.map +1 -1
- package/dist/compose/townhouse-dev.yml +1 -1
- package/dist/compose/townhouse-hs.yml +126 -19
- package/dist/{demo-MJR47QHZ.js → demo-3DWRDMYY.js} +3 -2
- package/dist/{demo-MJR47QHZ.js.map → demo-3DWRDMYY.js.map} +1 -1
- package/dist/image-manifest.json +12 -12
- package/dist/index.d.ts +1258 -659
- package/dist/index.js +36 -140
- package/dist/index.js.map +1 -1
- package/dist/manager-SsneW_Mj.d.ts +519 -0
- package/dist/rsa-from-seed-VMNLNDZM.js +62 -0
- package/dist/rsa-from-seed-VMNLNDZM.js.map +1 -0
- package/dist/tui-OIFXGBTL.js +625 -0
- package/dist/tui-OIFXGBTL.js.map +1 -0
- package/package.json +18 -2
- package/dist/chunk-IB6TNCUQ.js.map +0 -1
- package/dist/chunk-UTFWPLTB.js.map +0 -1
|
@@ -8,11 +8,13 @@ import {
|
|
|
8
8
|
DVM_HEALTH_PORT,
|
|
9
9
|
MILL_HEALTH_PORT,
|
|
10
10
|
NODE_BTP_PORT,
|
|
11
|
-
TOWN_HEALTH_PORT
|
|
11
|
+
TOWN_HEALTH_PORT
|
|
12
|
+
} from "./chunk-GQNBZJ6F.js";
|
|
13
|
+
import {
|
|
12
14
|
__commonJS,
|
|
13
15
|
__require,
|
|
14
16
|
__toESM
|
|
15
|
-
} from "./chunk-
|
|
17
|
+
} from "./chunk-I2R4CRUX.js";
|
|
16
18
|
|
|
17
19
|
// ../../node_modules/.pnpm/ws@8.19.0/node_modules/ws/lib/constants.js
|
|
18
20
|
var require_constants = __commonJS({
|
|
@@ -41,7 +43,7 @@ var require_constants = __commonJS({
|
|
|
41
43
|
var require_node_gyp_build = __commonJS({
|
|
42
44
|
"../../node_modules/.pnpm/node-gyp-build@4.8.4/node_modules/node-gyp-build/node-gyp-build.js"(exports, module) {
|
|
43
45
|
"use strict";
|
|
44
|
-
var
|
|
46
|
+
var fs6 = __require("fs");
|
|
45
47
|
var path = __require("path");
|
|
46
48
|
var os = __require("os");
|
|
47
49
|
var runtimeRequire = typeof __webpack_require__ === "function" ? __non_webpack_require__ : __require;
|
|
@@ -71,9 +73,9 @@ var require_node_gyp_build = __commonJS({
|
|
|
71
73
|
var debug = getFirst(path.join(dir, "build/Debug"), matchBuild);
|
|
72
74
|
if (debug) return debug;
|
|
73
75
|
}
|
|
74
|
-
var prebuild =
|
|
76
|
+
var prebuild = resolve2(dir);
|
|
75
77
|
if (prebuild) return prebuild;
|
|
76
|
-
var nearby =
|
|
78
|
+
var nearby = resolve2(path.dirname(process.execPath));
|
|
77
79
|
if (nearby) return nearby;
|
|
78
80
|
var target = [
|
|
79
81
|
"platform=" + platform,
|
|
@@ -89,7 +91,7 @@ var require_node_gyp_build = __commonJS({
|
|
|
89
91
|
// eslint-disable-line
|
|
90
92
|
].filter(Boolean).join(" ");
|
|
91
93
|
throw new Error("No native build was found for " + target + "\n loaded from: " + dir + "\n");
|
|
92
|
-
function
|
|
94
|
+
function resolve2(dir2) {
|
|
93
95
|
var tuples = readdirSync(path.join(dir2, "prebuilds")).map(parseTuple);
|
|
94
96
|
var tuple = tuples.filter(matchTuple(platform, arch)).sort(compareTuples)[0];
|
|
95
97
|
if (!tuple) return;
|
|
@@ -102,7 +104,7 @@ var require_node_gyp_build = __commonJS({
|
|
|
102
104
|
};
|
|
103
105
|
function readdirSync(dir) {
|
|
104
106
|
try {
|
|
105
|
-
return
|
|
107
|
+
return fs6.readdirSync(dir);
|
|
106
108
|
} catch (err) {
|
|
107
109
|
return [];
|
|
108
110
|
}
|
|
@@ -196,7 +198,7 @@ var require_node_gyp_build = __commonJS({
|
|
|
196
198
|
return typeof window !== "undefined" && window.process && window.process.type === "renderer";
|
|
197
199
|
}
|
|
198
200
|
function isAlpine(platform2) {
|
|
199
|
-
return platform2 === "linux" &&
|
|
201
|
+
return platform2 === "linux" && fs6.existsSync("/etc/alpine-release");
|
|
200
202
|
}
|
|
201
203
|
load.parseTags = parseTags;
|
|
202
204
|
load.matchTags = matchTags;
|
|
@@ -2327,7 +2329,7 @@ var require_extension = __commonJS({
|
|
|
2327
2329
|
if (dest[name] === void 0) dest[name] = [elem];
|
|
2328
2330
|
else dest[name].push(elem);
|
|
2329
2331
|
}
|
|
2330
|
-
function
|
|
2332
|
+
function parse3(header) {
|
|
2331
2333
|
const offers = /* @__PURE__ */ Object.create(null);
|
|
2332
2334
|
let params = /* @__PURE__ */ Object.create(null);
|
|
2333
2335
|
let mustUnescape = false;
|
|
@@ -2467,7 +2469,7 @@ var require_extension = __commonJS({
|
|
|
2467
2469
|
}).join(", ");
|
|
2468
2470
|
}).join(", ");
|
|
2469
2471
|
}
|
|
2470
|
-
module.exports = { format, parse:
|
|
2472
|
+
module.exports = { format, parse: parse3 };
|
|
2471
2473
|
}
|
|
2472
2474
|
});
|
|
2473
2475
|
|
|
@@ -2480,7 +2482,7 @@ var require_websocket = __commonJS({
|
|
|
2480
2482
|
var http3 = __require("http");
|
|
2481
2483
|
var net2 = __require("net");
|
|
2482
2484
|
var tls = __require("tls");
|
|
2483
|
-
var { randomBytes: randomBytes2, createHash } = __require("crypto");
|
|
2485
|
+
var { randomBytes: randomBytes2, createHash: createHash2 } = __require("crypto");
|
|
2484
2486
|
var { Duplex, Readable } = __require("stream");
|
|
2485
2487
|
var { URL: URL2 } = __require("url");
|
|
2486
2488
|
var PerMessageDeflate = require_permessage_deflate();
|
|
@@ -2501,7 +2503,7 @@ var require_websocket = __commonJS({
|
|
|
2501
2503
|
var {
|
|
2502
2504
|
EventTarget: { addEventListener, removeEventListener }
|
|
2503
2505
|
} = require_event_target();
|
|
2504
|
-
var { format, parse:
|
|
2506
|
+
var { format, parse: parse3 } = require_extension();
|
|
2505
2507
|
var { toBuffer } = require_buffer_util();
|
|
2506
2508
|
var kAborted = /* @__PURE__ */ Symbol("kAborted");
|
|
2507
2509
|
var protocolVersions = [8, 13];
|
|
@@ -3140,7 +3142,7 @@ var require_websocket = __commonJS({
|
|
|
3140
3142
|
abortHandshake(websocket2, socket, "Invalid Upgrade header");
|
|
3141
3143
|
return;
|
|
3142
3144
|
}
|
|
3143
|
-
const digest =
|
|
3145
|
+
const digest = createHash2("sha1").update(key + GUID).digest("base64");
|
|
3144
3146
|
if (res.headers["sec-websocket-accept"] !== digest) {
|
|
3145
3147
|
abortHandshake(websocket2, socket, "Invalid Sec-WebSocket-Accept header");
|
|
3146
3148
|
return;
|
|
@@ -3170,7 +3172,7 @@ var require_websocket = __commonJS({
|
|
|
3170
3172
|
}
|
|
3171
3173
|
let extensions;
|
|
3172
3174
|
try {
|
|
3173
|
-
extensions =
|
|
3175
|
+
extensions = parse3(secWebSocketExtensions);
|
|
3174
3176
|
} catch (err) {
|
|
3175
3177
|
const message = "Invalid Sec-WebSocket-Extensions header";
|
|
3176
3178
|
abortHandshake(websocket2, socket, message);
|
|
@@ -3460,7 +3462,7 @@ var require_subprotocol = __commonJS({
|
|
|
3460
3462
|
"../../node_modules/.pnpm/ws@8.19.0/node_modules/ws/lib/subprotocol.js"(exports, module) {
|
|
3461
3463
|
"use strict";
|
|
3462
3464
|
var { tokenChars } = require_validation();
|
|
3463
|
-
function
|
|
3465
|
+
function parse3(header) {
|
|
3464
3466
|
const protocols = /* @__PURE__ */ new Set();
|
|
3465
3467
|
let start = -1;
|
|
3466
3468
|
let end = -1;
|
|
@@ -3496,7 +3498,7 @@ var require_subprotocol = __commonJS({
|
|
|
3496
3498
|
protocols.add(protocol);
|
|
3497
3499
|
return protocols;
|
|
3498
3500
|
}
|
|
3499
|
-
module.exports = { parse:
|
|
3501
|
+
module.exports = { parse: parse3 };
|
|
3500
3502
|
}
|
|
3501
3503
|
});
|
|
3502
3504
|
|
|
@@ -3507,7 +3509,7 @@ var require_websocket_server = __commonJS({
|
|
|
3507
3509
|
var EventEmitter2 = __require("events");
|
|
3508
3510
|
var http3 = __require("http");
|
|
3509
3511
|
var { Duplex } = __require("stream");
|
|
3510
|
-
var { createHash } = __require("crypto");
|
|
3512
|
+
var { createHash: createHash2 } = __require("crypto");
|
|
3511
3513
|
var extension = require_extension();
|
|
3512
3514
|
var PerMessageDeflate = require_permessage_deflate();
|
|
3513
3515
|
var subprotocol = require_subprotocol();
|
|
@@ -3808,7 +3810,7 @@ var require_websocket_server = __commonJS({
|
|
|
3808
3810
|
);
|
|
3809
3811
|
}
|
|
3810
3812
|
if (this._state > RUNNING) return abortHandshake(socket, 503);
|
|
3811
|
-
const digest =
|
|
3813
|
+
const digest = createHash2("sha1").update(key + GUID).digest("base64");
|
|
3812
3814
|
const headers = [
|
|
3813
3815
|
"HTTP/1.1 101 Switching Protocols",
|
|
3814
3816
|
"Upgrade: websocket",
|
|
@@ -3896,6 +3898,16 @@ var require_websocket_server = __commonJS({
|
|
|
3896
3898
|
// src/config/defaults.ts
|
|
3897
3899
|
import { homedir } from "os";
|
|
3898
3900
|
import { join } from "path";
|
|
3901
|
+
var DEFAULT_HS_CHAIN_PROVIDERS = [
|
|
3902
|
+
Object.freeze({
|
|
3903
|
+
chainType: "evm",
|
|
3904
|
+
chainId: "evm:base:31337",
|
|
3905
|
+
rpcUrl: "http://127.0.0.1:19999",
|
|
3906
|
+
registryAddress: "0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512",
|
|
3907
|
+
tokenAddress: "0x5FbDB2315678afecb367f032d93F642f64180aa3",
|
|
3908
|
+
keyId: "0x7c852118294e51e653712a81e05800f419141751be58f605c371e15141b007a6"
|
|
3909
|
+
})
|
|
3910
|
+
];
|
|
3899
3911
|
function getDefaultConfig() {
|
|
3900
3912
|
return {
|
|
3901
3913
|
nodes: {
|
|
@@ -3924,6 +3936,8 @@ function getDefaultConfig() {
|
|
|
3924
3936
|
}
|
|
3925
3937
|
|
|
3926
3938
|
// src/config/validator.ts
|
|
3939
|
+
var VALID_CHAIN_TYPES = /* @__PURE__ */ new Set(["evm"]);
|
|
3940
|
+
var HEX_ADDRESS = /^0x[a-fA-F0-9]+$/;
|
|
3927
3941
|
var ConfigValidationError = class extends Error {
|
|
3928
3942
|
constructor(message) {
|
|
3929
3943
|
super(message);
|
|
@@ -4094,6 +4108,52 @@ function validateConfig(raw) {
|
|
|
4094
4108
|
'config.transport.mode="ator" requires either config.transport.externalUrl (operator-managed anon binary) or config.transport.hiddenService (connector-managed anon binary). Without one of these, the underlying connector will reject the manifest at boot.'
|
|
4095
4109
|
);
|
|
4096
4110
|
}
|
|
4111
|
+
let chainProviders;
|
|
4112
|
+
if (raw["chainProviders"] !== void 0) {
|
|
4113
|
+
if (!Array.isArray(raw["chainProviders"])) {
|
|
4114
|
+
throw new ConfigValidationError(
|
|
4115
|
+
"config.chainProviders must be an array of ChainProviderEntry"
|
|
4116
|
+
);
|
|
4117
|
+
}
|
|
4118
|
+
chainProviders = raw["chainProviders"].map((entry, idx) => {
|
|
4119
|
+
const path = `config.chainProviders[${idx}]`;
|
|
4120
|
+
assertObject(entry, path);
|
|
4121
|
+
assertString(entry["chainType"], `${path}.chainType`);
|
|
4122
|
+
if (!VALID_CHAIN_TYPES.has(entry["chainType"])) {
|
|
4123
|
+
throw new ConfigValidationError(
|
|
4124
|
+
`${path}.chainType must be one of: ${[...VALID_CHAIN_TYPES].join(", ")}`
|
|
4125
|
+
);
|
|
4126
|
+
}
|
|
4127
|
+
assertString(entry["chainId"], `${path}.chainId`);
|
|
4128
|
+
assertString(entry["rpcUrl"], `${path}.rpcUrl`);
|
|
4129
|
+
assertString(entry["registryAddress"], `${path}.registryAddress`);
|
|
4130
|
+
if (!HEX_ADDRESS.test(entry["registryAddress"])) {
|
|
4131
|
+
throw new ConfigValidationError(
|
|
4132
|
+
`${path}.registryAddress must match /^0x[a-fA-F0-9]+$/`
|
|
4133
|
+
);
|
|
4134
|
+
}
|
|
4135
|
+
assertString(entry["tokenAddress"], `${path}.tokenAddress`);
|
|
4136
|
+
if (!HEX_ADDRESS.test(entry["tokenAddress"])) {
|
|
4137
|
+
throw new ConfigValidationError(
|
|
4138
|
+
`${path}.tokenAddress must match /^0x[a-fA-F0-9]+$/`
|
|
4139
|
+
);
|
|
4140
|
+
}
|
|
4141
|
+
assertString(entry["keyId"], `${path}.keyId`);
|
|
4142
|
+
if (!HEX_ADDRESS.test(entry["keyId"])) {
|
|
4143
|
+
throw new ConfigValidationError(
|
|
4144
|
+
`${path}.keyId must match /^0x[a-fA-F0-9]+$/`
|
|
4145
|
+
);
|
|
4146
|
+
}
|
|
4147
|
+
return {
|
|
4148
|
+
chainType: entry["chainType"],
|
|
4149
|
+
chainId: entry["chainId"],
|
|
4150
|
+
rpcUrl: entry["rpcUrl"],
|
|
4151
|
+
registryAddress: entry["registryAddress"],
|
|
4152
|
+
tokenAddress: entry["tokenAddress"],
|
|
4153
|
+
keyId: entry["keyId"]
|
|
4154
|
+
};
|
|
4155
|
+
});
|
|
4156
|
+
}
|
|
4097
4157
|
assertObject(raw["api"], "config.api");
|
|
4098
4158
|
const api = raw["api"];
|
|
4099
4159
|
assertNumber(api["port"], "config.api.port");
|
|
@@ -4144,7 +4204,8 @@ function validateConfig(raw) {
|
|
|
4144
4204
|
},
|
|
4145
4205
|
logging: {
|
|
4146
4206
|
level: logging["level"]
|
|
4147
|
-
}
|
|
4207
|
+
},
|
|
4208
|
+
...chainProviders !== void 0 ? { chainProviders } : {}
|
|
4148
4209
|
};
|
|
4149
4210
|
}
|
|
4150
4211
|
function pickOptional(obj, keys) {
|
|
@@ -4353,6 +4414,16 @@ var ConnectorConfigGenerator = class {
|
|
|
4353
4414
|
peers: [],
|
|
4354
4415
|
routes: []
|
|
4355
4416
|
};
|
|
4417
|
+
if (this.config.chainProviders !== void 0 && this.config.chainProviders.length > 0) {
|
|
4418
|
+
yamlObj["chainProviders"] = this.config.chainProviders.map((p) => ({
|
|
4419
|
+
chainType: p.chainType,
|
|
4420
|
+
chainId: p.chainId,
|
|
4421
|
+
rpcUrl: p.rpcUrl,
|
|
4422
|
+
registryAddress: p.registryAddress,
|
|
4423
|
+
tokenAddress: p.tokenAddress,
|
|
4424
|
+
keyId: p.keyId
|
|
4425
|
+
}));
|
|
4426
|
+
}
|
|
4356
4427
|
return yamlStringify(yamlObj);
|
|
4357
4428
|
}
|
|
4358
4429
|
// ── Private helpers ──
|
|
@@ -4435,151 +4506,956 @@ var ConnectorConfigGenerator = class {
|
|
|
4435
4506
|
}
|
|
4436
4507
|
};
|
|
4437
4508
|
|
|
4438
|
-
// src/
|
|
4439
|
-
|
|
4440
|
-
|
|
4441
|
-
|
|
4442
|
-
|
|
4443
|
-
|
|
4444
|
-
|
|
4445
|
-
|
|
4446
|
-
|
|
4447
|
-
|
|
4448
|
-
|
|
4449
|
-
|
|
4450
|
-
var MAX_START_RETRIES = 3;
|
|
4451
|
-
var CONNECTOR_INTERNAL_PORT = 3e3;
|
|
4452
|
-
var RELAY_ATOR_SIDECAR_IMAGE = "toon:townhouse-ator-sidecar";
|
|
4453
|
-
var RELAY_ATOR_SOCKS_PORT = 9051;
|
|
4454
|
-
function normalizeImageTag(image) {
|
|
4455
|
-
const lastSlash = image.lastIndexOf("/");
|
|
4456
|
-
const nameAndTag = lastSlash >= 0 ? image.slice(lastSlash + 1) : image;
|
|
4457
|
-
if (nameAndTag.includes(":")) {
|
|
4458
|
-
return image;
|
|
4509
|
+
// src/connector/admin-client.ts
|
|
4510
|
+
var DEFAULT_TIMEOUT_MS = 5e3;
|
|
4511
|
+
var ConnectorAdminClient = class {
|
|
4512
|
+
baseUrl;
|
|
4513
|
+
timeoutMs;
|
|
4514
|
+
/**
|
|
4515
|
+
* @param baseUrl - Base URL for the connector admin API (e.g., 'http://localhost:9402')
|
|
4516
|
+
* @param timeoutMs - Request timeout in milliseconds (default: 5000)
|
|
4517
|
+
*/
|
|
4518
|
+
constructor(baseUrl, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
4519
|
+
this.baseUrl = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
|
|
4520
|
+
this.timeoutMs = timeoutMs;
|
|
4459
4521
|
}
|
|
4460
|
-
|
|
4461
|
-
|
|
4462
|
-
|
|
4463
|
-
docker;
|
|
4464
|
-
config;
|
|
4465
|
-
configGenerator;
|
|
4466
|
-
walletManager;
|
|
4467
|
-
activeNodes = [];
|
|
4468
|
-
statsCache = /* @__PURE__ */ new Map();
|
|
4469
|
-
constructor(docker, config, walletManager) {
|
|
4470
|
-
super();
|
|
4471
|
-
this.docker = docker;
|
|
4472
|
-
this.config = config;
|
|
4473
|
-
this.configGenerator = new ConnectorConfigGenerator(config);
|
|
4474
|
-
this.walletManager = walletManager;
|
|
4522
|
+
/** Public read of the configured base URL (used by drill-command probes to derive a sibling client). */
|
|
4523
|
+
getBaseUrl() {
|
|
4524
|
+
return this.baseUrl;
|
|
4475
4525
|
}
|
|
4476
4526
|
/**
|
|
4477
|
-
*
|
|
4478
|
-
*
|
|
4479
|
-
*
|
|
4480
|
-
*
|
|
4481
|
-
*
|
|
4527
|
+
* GET /health on the admin-API port — checks HTTP reachability of the
|
|
4528
|
+
* connector without validating the rich HealthStatus shape. Use this from
|
|
4529
|
+
* the drill-command health probe when only the admin URL is available
|
|
4530
|
+
* (port 9401), not the healthCheckPort (8080). The admin server's /health
|
|
4531
|
+
* returns `{status:'healthy', service:'admin-api', nodeId, timestamp}` —
|
|
4532
|
+
* a different shape from `getHealth()`'s validator. This method returns
|
|
4533
|
+
* a coarse status from a 200 response and reads `nodeId` if present.
|
|
4534
|
+
*
|
|
4535
|
+
* @throws Error when connector is unreachable or returns non-2xx.
|
|
4482
4536
|
*/
|
|
4483
|
-
async
|
|
4484
|
-
|
|
4485
|
-
await
|
|
4486
|
-
|
|
4487
|
-
|
|
4488
|
-
await this.waitForHealth("townhouse-connector");
|
|
4489
|
-
await Promise.all(profiles.map((type) => this.startNode(type)));
|
|
4490
|
-
if (profiles.includes("town") && this.config.transport.relayHiddenService) {
|
|
4491
|
-
await this.startRelayAtorSidecar();
|
|
4492
|
-
}
|
|
4537
|
+
async pingAdminLive() {
|
|
4538
|
+
const response = await this.fetch("/health");
|
|
4539
|
+
const body = await response.json().catch(() => ({}));
|
|
4540
|
+
const nodeId = typeof body === "object" && body !== null && typeof body["nodeId"] === "string" ? body["nodeId"] : void 0;
|
|
4541
|
+
return nodeId !== void 0 ? { status: "healthy", nodeId } : { status: "healthy" };
|
|
4493
4542
|
}
|
|
4494
4543
|
/**
|
|
4495
|
-
*
|
|
4496
|
-
* with updated environment variables (peer list).
|
|
4544
|
+
* GET /health — returns the connector's HealthStatus from the healthCheckPort server.
|
|
4497
4545
|
*
|
|
4498
|
-
*
|
|
4546
|
+
* @throws Error when connector is not running, returns non-200, or shape is invalid
|
|
4499
4547
|
*/
|
|
4500
|
-
async
|
|
4501
|
-
|
|
4502
|
-
|
|
4503
|
-
|
|
4504
|
-
|
|
4505
|
-
try {
|
|
4506
|
-
await existingContainer.stop({ t: 5 });
|
|
4507
|
-
} catch {
|
|
4548
|
+
async getHealth() {
|
|
4549
|
+
const response = await this.fetch("/health");
|
|
4550
|
+
const body = await response.json();
|
|
4551
|
+
if (typeof body !== "object" || body === null) {
|
|
4552
|
+
throw new Error("Connector admin API: invalid health response shape");
|
|
4508
4553
|
}
|
|
4509
|
-
|
|
4510
|
-
|
|
4511
|
-
|
|
4554
|
+
const obj = body;
|
|
4555
|
+
const status = obj["status"];
|
|
4556
|
+
if (status !== "healthy" && status !== "unhealthy" && status !== "starting" && status !== "degraded") {
|
|
4557
|
+
throw new Error("Connector admin API: invalid health response shape");
|
|
4512
4558
|
}
|
|
4513
|
-
|
|
4514
|
-
|
|
4515
|
-
await this.startConnector();
|
|
4516
|
-
await this.waitForHealth(connectorName);
|
|
4517
|
-
} finally {
|
|
4518
|
-
this.emit("connectorRestarted", { peers: activeNodes });
|
|
4559
|
+
if (typeof obj["uptime"] !== "number" || typeof obj["peersConnected"] !== "number" || typeof obj["totalPeers"] !== "number" || typeof obj["timestamp"] !== "string") {
|
|
4560
|
+
throw new Error("Connector admin API: invalid health response shape");
|
|
4519
4561
|
}
|
|
4562
|
+
return body;
|
|
4520
4563
|
}
|
|
4521
4564
|
/**
|
|
4522
|
-
*
|
|
4523
|
-
*
|
|
4565
|
+
* GET /admin/hs-hostname — returns the connector's published .anyone hidden-service
|
|
4566
|
+
* hostname (Epic 45 / Story 44.1). Returns 200 with {hostname, publishedAt} both
|
|
4567
|
+
* possibly null while bootstrap is in progress, both non-null once anon publishes.
|
|
4568
|
+
* Returns 503 when the connector is anon-disabled (anon.enabled: false in config).
|
|
4569
|
+
*
|
|
4570
|
+
* @throws Error('connector is anon-disabled (HTTP 503)') on 503 — caller can match
|
|
4571
|
+
* on this exact prefix for actionable diagnostics.
|
|
4572
|
+
* @throws Error on non-200/503 status, network error, or shape-validation failure.
|
|
4524
4573
|
*/
|
|
4525
|
-
async
|
|
4526
|
-
|
|
4527
|
-
|
|
4574
|
+
async getHsHostname() {
|
|
4575
|
+
const url = `${this.baseUrl}/admin/hs-hostname`;
|
|
4576
|
+
const controller = new AbortController();
|
|
4577
|
+
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
4578
|
+
let body;
|
|
4579
|
+
try {
|
|
4580
|
+
let response;
|
|
4581
|
+
try {
|
|
4582
|
+
response = await fetch(url, { signal: controller.signal });
|
|
4583
|
+
} catch (error) {
|
|
4584
|
+
if (error instanceof Error && error.name === "AbortError") {
|
|
4585
|
+
throw new Error(
|
|
4586
|
+
`Connector admin API request timeout after ${this.timeoutMs}ms: ${url}`
|
|
4587
|
+
);
|
|
4588
|
+
}
|
|
4589
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
4590
|
+
throw new Error(`Connector admin API connection refused: ${msg}`);
|
|
4591
|
+
}
|
|
4592
|
+
if (response.status === 503) {
|
|
4593
|
+
throw new Error("connector is anon-disabled (HTTP 503)");
|
|
4594
|
+
}
|
|
4595
|
+
if (!response.ok) {
|
|
4596
|
+
throw new Error(
|
|
4597
|
+
`Connector admin API unexpected status ${response.status} on /admin/hs-hostname \u2014 expected 200 or 503 (connector image may be too old or misconfigured)`
|
|
4598
|
+
);
|
|
4599
|
+
}
|
|
4600
|
+
try {
|
|
4601
|
+
body = await response.json();
|
|
4602
|
+
} catch (error) {
|
|
4603
|
+
if (error instanceof Error && error.name === "AbortError") {
|
|
4604
|
+
throw new Error(
|
|
4605
|
+
`Connector admin API request timeout after ${this.timeoutMs}ms: ${url}`
|
|
4606
|
+
);
|
|
4607
|
+
}
|
|
4608
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
4609
|
+
throw new Error(
|
|
4610
|
+
`Connector admin API: invalid JSON in hs-hostname response: ${msg}`
|
|
4611
|
+
);
|
|
4612
|
+
}
|
|
4613
|
+
} finally {
|
|
4614
|
+
clearTimeout(timer);
|
|
4528
4615
|
}
|
|
4529
|
-
|
|
4530
|
-
|
|
4616
|
+
if (typeof body !== "object" || body === null) {
|
|
4617
|
+
throw new Error(
|
|
4618
|
+
"Connector admin API: invalid hs-hostname response shape"
|
|
4619
|
+
);
|
|
4620
|
+
}
|
|
4621
|
+
const obj = body;
|
|
4622
|
+
const hostname = obj["hostname"];
|
|
4623
|
+
const publishedAt = obj["publishedAt"];
|
|
4624
|
+
if (hostname !== null && typeof hostname !== "string" || publishedAt !== null && typeof publishedAt !== "string") {
|
|
4625
|
+
throw new Error(
|
|
4626
|
+
"Connector admin API: invalid hs-hostname response shape"
|
|
4627
|
+
);
|
|
4628
|
+
}
|
|
4629
|
+
if (typeof hostname === "string" && hostname.length === 0) {
|
|
4630
|
+
throw new Error(
|
|
4631
|
+
"Connector admin API: invalid hs-hostname response shape"
|
|
4632
|
+
);
|
|
4633
|
+
}
|
|
4634
|
+
if (typeof publishedAt === "string" && publishedAt.length === 0) {
|
|
4635
|
+
throw new Error(
|
|
4636
|
+
"Connector admin API: invalid hs-hostname response shape"
|
|
4637
|
+
);
|
|
4638
|
+
}
|
|
4639
|
+
if (typeof hostname === "string" && !hostname.endsWith(".anon")) {
|
|
4640
|
+
throw new Error(
|
|
4641
|
+
"Connector admin API: invalid hs-hostname response shape"
|
|
4642
|
+
);
|
|
4643
|
+
}
|
|
4644
|
+
return body;
|
|
4531
4645
|
}
|
|
4532
4646
|
/**
|
|
4533
|
-
*
|
|
4534
|
-
*
|
|
4647
|
+
* GET /admin/metrics.json — returns the connector's per-peer ILP counters
|
|
4648
|
+
* with an aggregate rollup, mirroring `AdminMetricsJsonResponse`.
|
|
4649
|
+
*
|
|
4650
|
+
* @throws Error when connector is not running, returns non-200, or shape is invalid
|
|
4535
4651
|
*/
|
|
4536
|
-
async
|
|
4537
|
-
|
|
4538
|
-
const
|
|
4539
|
-
|
|
4540
|
-
|
|
4652
|
+
async getMetrics() {
|
|
4653
|
+
const response = await this.fetch("/admin/metrics.json");
|
|
4654
|
+
const body = await response.json();
|
|
4655
|
+
if (typeof body !== "object" || body === null) {
|
|
4656
|
+
throw new Error("Connector admin API: invalid metrics response shape");
|
|
4657
|
+
}
|
|
4658
|
+
const obj = body;
|
|
4659
|
+
const aggregate = obj["aggregate"];
|
|
4660
|
+
if (typeof obj["uptimeSeconds"] !== "number" || typeof aggregate !== "object" || aggregate === null || !Array.isArray(obj["peers"]) || typeof obj["timestamp"] !== "string") {
|
|
4661
|
+
throw new Error("Connector admin API: invalid metrics response shape");
|
|
4662
|
+
}
|
|
4663
|
+
const agg = aggregate;
|
|
4664
|
+
if (typeof agg["packetsForwarded"] !== "number" || typeof agg["packetsRejected"] !== "number" || typeof agg["bytesSent"] !== "number") {
|
|
4665
|
+
throw new Error("Connector admin API: invalid metrics response shape");
|
|
4666
|
+
}
|
|
4667
|
+
return body;
|
|
4541
4668
|
}
|
|
4542
4669
|
/**
|
|
4543
|
-
*
|
|
4544
|
-
*
|
|
4545
|
-
*
|
|
4546
|
-
*
|
|
4670
|
+
* GET /admin/earnings.json — returns the connector's per-peer per-asset
|
|
4671
|
+
* earnings projection, mirroring `AdminEarningsJsonResponse` (connector v3.2.0+).
|
|
4672
|
+
*
|
|
4673
|
+
* Source of truth: @toon-protocol/connector
|
|
4674
|
+
* packages/connector/src/http/admin-api.ts:1864-1945
|
|
4675
|
+
*
|
|
4676
|
+
* Returns HTTP 503 when the connector is started without settlement config
|
|
4677
|
+
* (accountManager / claimReceiver not wired). Townhouse's apex always wires
|
|
4678
|
+
* both; 503 in production indicates connector misconfiguration.
|
|
4679
|
+
*
|
|
4680
|
+
* Wire-shape adaptation: the connector's `timestamp: string` field is
|
|
4681
|
+
* wrapped into `{ iso: string }` on the way out (EarningsTimestamp).
|
|
4682
|
+
*
|
|
4683
|
+
* @throws Error when connector is not running, returns non-200, or shape is invalid
|
|
4547
4684
|
*/
|
|
4548
|
-
async
|
|
4549
|
-
const
|
|
4550
|
-
const
|
|
4551
|
-
|
|
4552
|
-
|
|
4553
|
-
|
|
4554
|
-
|
|
4555
|
-
|
|
4556
|
-
|
|
4557
|
-
|
|
4558
|
-
|
|
4559
|
-
|
|
4685
|
+
async getEarnings() {
|
|
4686
|
+
const response = await this.fetch("/admin/earnings.json");
|
|
4687
|
+
const body = await response.json();
|
|
4688
|
+
if (typeof body !== "object" || body === null) {
|
|
4689
|
+
throw new Error("Connector admin API: invalid earnings response shape");
|
|
4690
|
+
}
|
|
4691
|
+
const obj = body;
|
|
4692
|
+
if (typeof obj["uptimeSeconds"] !== "number" || !Array.isArray(obj["peers"]) || !Array.isArray(obj["connectorFees"]) || !Array.isArray(obj["recentClaims"]) || typeof obj["timestamp"] !== "string") {
|
|
4693
|
+
throw new Error("Connector admin API: invalid earnings response shape");
|
|
4694
|
+
}
|
|
4695
|
+
const peers = obj["peers"];
|
|
4696
|
+
for (const peer of peers) {
|
|
4697
|
+
if (typeof peer !== "object" || peer === null) {
|
|
4698
|
+
throw new Error("Connector admin API: invalid earnings response shape");
|
|
4699
|
+
}
|
|
4700
|
+
const p = peer;
|
|
4701
|
+
if (typeof p["peerId"] !== "string" || !Array.isArray(p["byAsset"])) {
|
|
4702
|
+
throw new Error("Connector admin API: invalid earnings response shape");
|
|
4703
|
+
}
|
|
4704
|
+
for (const asset of p["byAsset"]) {
|
|
4705
|
+
if (typeof asset !== "object" || asset === null) {
|
|
4706
|
+
throw new Error(
|
|
4707
|
+
"Connector admin API: invalid earnings response shape"
|
|
4708
|
+
);
|
|
4709
|
+
}
|
|
4710
|
+
const a = asset;
|
|
4711
|
+
if (typeof a["assetCode"] !== "string" || typeof a["assetScale"] !== "number" || typeof a["claimsReceivedTotal"] !== "string" || typeof a["claimsSentTotal"] !== "string" || typeof a["netBalance"] !== "string" || a["lastClaimAt"] !== null && typeof a["lastClaimAt"] !== "string") {
|
|
4712
|
+
throw new Error(
|
|
4713
|
+
"Connector admin API: invalid earnings response shape"
|
|
4714
|
+
);
|
|
4560
4715
|
}
|
|
4561
4716
|
}
|
|
4562
4717
|
}
|
|
4563
|
-
|
|
4564
|
-
|
|
4565
|
-
|
|
4566
|
-
|
|
4567
|
-
|
|
4718
|
+
const fees = obj["connectorFees"];
|
|
4719
|
+
for (const fee of fees) {
|
|
4720
|
+
if (typeof fee !== "object" || fee === null) {
|
|
4721
|
+
throw new Error("Connector admin API: invalid earnings response shape");
|
|
4722
|
+
}
|
|
4723
|
+
const f = fee;
|
|
4724
|
+
if (typeof f["assetCode"] !== "string" || typeof f["assetScale"] !== "number" || typeof f["total"] !== "string") {
|
|
4725
|
+
throw new Error("Connector admin API: invalid earnings response shape");
|
|
4726
|
+
}
|
|
4568
4727
|
}
|
|
4569
|
-
|
|
4728
|
+
const claims = obj["recentClaims"];
|
|
4729
|
+
for (const claim of claims) {
|
|
4730
|
+
if (typeof claim !== "object" || claim === null) {
|
|
4731
|
+
throw new Error("Connector admin API: invalid earnings response shape");
|
|
4732
|
+
}
|
|
4733
|
+
const c = claim;
|
|
4734
|
+
if (typeof c["peerId"] !== "string" || typeof c["assetCode"] !== "string" || typeof c["assetScale"] !== "number" || typeof c["amount"] !== "string" || c["direction"] !== "inbound" && c["direction"] !== "outbound" || typeof c["at"] !== "string") {
|
|
4735
|
+
throw new Error("Connector admin API: invalid earnings response shape");
|
|
4736
|
+
}
|
|
4737
|
+
}
|
|
4738
|
+
const timestamp = { iso: obj["timestamp"] };
|
|
4739
|
+
return {
|
|
4740
|
+
uptimeSeconds: obj["uptimeSeconds"],
|
|
4741
|
+
peers,
|
|
4742
|
+
connectorFees: fees,
|
|
4743
|
+
recentClaims: claims,
|
|
4744
|
+
timestamp
|
|
4745
|
+
};
|
|
4570
4746
|
}
|
|
4571
4747
|
/**
|
|
4572
|
-
*
|
|
4573
|
-
*
|
|
4574
|
-
*
|
|
4575
|
-
* the relay WebSocket (7100/tcp). Falls back to the Docker-internal URL
|
|
4576
|
-
* when the server is running inside the Docker network or bindings are absent.
|
|
4748
|
+
* GET /admin/peers — returns the connector's peer roster with route counts
|
|
4749
|
+
* and ILP addresses. Returns the unwrapped peers array (the wrapper's
|
|
4750
|
+
* nodeId / peerCount / connectedCount fields are dropped).
|
|
4577
4751
|
*
|
|
4578
|
-
* @
|
|
4752
|
+
* @throws Error when connector is not running, returns non-200, or shape is invalid
|
|
4579
4753
|
*/
|
|
4580
|
-
async
|
|
4581
|
-
const
|
|
4582
|
-
|
|
4754
|
+
async getPeers() {
|
|
4755
|
+
const response = await this.fetch("/admin/peers");
|
|
4756
|
+
const body = await response.json();
|
|
4757
|
+
if (typeof body !== "object" || body === null) {
|
|
4758
|
+
throw new Error("Connector admin API: invalid peers response shape");
|
|
4759
|
+
}
|
|
4760
|
+
const obj = body;
|
|
4761
|
+
if (!Array.isArray(obj["peers"])) {
|
|
4762
|
+
throw new Error("Connector admin API: invalid peers response shape");
|
|
4763
|
+
}
|
|
4764
|
+
return body.peers;
|
|
4765
|
+
}
|
|
4766
|
+
/**
|
|
4767
|
+
* GET /admin/channels — returns the connector's payment-channel summaries
|
|
4768
|
+
* across all registered chain providers. Multi-chain: one entry per channel
|
|
4769
|
+
* regardless of chain.
|
|
4770
|
+
*
|
|
4771
|
+
* @throws Error when connector is not running, returns non-200, or shape is invalid
|
|
4772
|
+
*/
|
|
4773
|
+
async getChannels() {
|
|
4774
|
+
const response = await this.fetch("/admin/channels");
|
|
4775
|
+
const body = await response.json();
|
|
4776
|
+
if (!Array.isArray(body)) {
|
|
4777
|
+
throw new Error("Connector admin API: invalid channels response shape");
|
|
4778
|
+
}
|
|
4779
|
+
for (const entry of body) {
|
|
4780
|
+
if (typeof entry !== "object" || entry === null) {
|
|
4781
|
+
throw new Error("Connector admin API: invalid channels response shape");
|
|
4782
|
+
}
|
|
4783
|
+
const e = entry;
|
|
4784
|
+
if (typeof e["channelId"] !== "string" || typeof e["peerId"] !== "string" || typeof e["chain"] !== "string" || typeof e["status"] !== "string" || typeof e["deposit"] !== "string" || typeof e["lastActivity"] !== "string") {
|
|
4785
|
+
throw new Error("Connector admin API: invalid channels response shape");
|
|
4786
|
+
}
|
|
4787
|
+
}
|
|
4788
|
+
return body;
|
|
4789
|
+
}
|
|
4790
|
+
/**
|
|
4791
|
+
* POST /admin/peers — register (or re-register, idempotent) a child peer
|
|
4792
|
+
* with the connector. Used by the boot reconciler (Story 46.1) to
|
|
4793
|
+
* re-register peers present in `nodes.yaml` but missing from the
|
|
4794
|
+
* connector's runtime peer roster (e.g., after a connector restart).
|
|
4795
|
+
*
|
|
4796
|
+
* The connector's POST /admin/peers handler treats a POST whose `id`
|
|
4797
|
+
* matches an existing peer as a re-registration (no-op for the peer
|
|
4798
|
+
* itself; routes are appended). A POST with a new `id` triggers
|
|
4799
|
+
* `addPeer()` and BTP connection setup.
|
|
4800
|
+
*
|
|
4801
|
+
* @param input.id - peer identifier (matches `nodes.yaml`'s `peerId` and
|
|
4802
|
+
* the connector's `PeerStatus.id`).
|
|
4803
|
+
* @param input.url - BTP WebSocket URL the connector dials. MUST start
|
|
4804
|
+
* with `ws://` or `wss://` (the connector validates this).
|
|
4805
|
+
* @param input.authToken - shared auth token; pass empty string for
|
|
4806
|
+
* internal Townhouse peers (no auth).
|
|
4807
|
+
* @param input.routes - optional ILP route prefixes to register against
|
|
4808
|
+
* this peer. The reconciler passes the peer's ilpAddress.
|
|
4809
|
+
* @param input.transport - optional per-peer transport selection
|
|
4810
|
+
* (connector >= 3.6.2). `'direct'` forces the connector to bypass the
|
|
4811
|
+
* global SOCKS5 transport for this peer, even when the apex itself
|
|
4812
|
+
* runs in `transport.type: socks5` mode. Required for Docker-sibling
|
|
4813
|
+
* peers in HS mode — the anon SOCKS5 proxy cannot resolve internal
|
|
4814
|
+
* Docker hostnames. When omitted, the peer inherits the connector's
|
|
4815
|
+
* global transport (back-compat with pre-3.6.2 connectors).
|
|
4816
|
+
*
|
|
4817
|
+
* @throws Error on non-2xx response, timeout, or connection refused.
|
|
4818
|
+
*/
|
|
4819
|
+
async registerPeer(input) {
|
|
4820
|
+
if (!input.url.startsWith("ws://") && !input.url.startsWith("wss://")) {
|
|
4821
|
+
throw new Error(
|
|
4822
|
+
`Connector admin API: registerPeer.url must start with ws:// or wss:// (got: ${input.url})`
|
|
4823
|
+
);
|
|
4824
|
+
}
|
|
4825
|
+
const url = `${this.baseUrl}/admin/peers`;
|
|
4826
|
+
const controller = new AbortController();
|
|
4827
|
+
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
4828
|
+
try {
|
|
4829
|
+
let response;
|
|
4830
|
+
try {
|
|
4831
|
+
response = await fetch(url, {
|
|
4832
|
+
method: "POST",
|
|
4833
|
+
headers: { "content-type": "application/json" },
|
|
4834
|
+
body: JSON.stringify(input),
|
|
4835
|
+
signal: controller.signal
|
|
4836
|
+
});
|
|
4837
|
+
} catch (error) {
|
|
4838
|
+
if (error instanceof Error && error.name === "AbortError") {
|
|
4839
|
+
throw new Error(
|
|
4840
|
+
`Connector admin API request timeout after ${this.timeoutMs}ms: POST ${url}`
|
|
4841
|
+
);
|
|
4842
|
+
}
|
|
4843
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
4844
|
+
throw new Error(
|
|
4845
|
+
`Connector admin API request failed: POST ${url} \u2014 ${msg}`
|
|
4846
|
+
);
|
|
4847
|
+
}
|
|
4848
|
+
if (!response.ok) {
|
|
4849
|
+
let body = "";
|
|
4850
|
+
try {
|
|
4851
|
+
body = await response.text();
|
|
4852
|
+
} catch (error) {
|
|
4853
|
+
if (error instanceof Error && error.name === "AbortError") {
|
|
4854
|
+
throw new Error(
|
|
4855
|
+
`Connector admin API request timeout after ${this.timeoutMs}ms: POST ${url} (body read)`
|
|
4856
|
+
);
|
|
4857
|
+
}
|
|
4858
|
+
}
|
|
4859
|
+
throw new Error(
|
|
4860
|
+
`Connector admin API error: POST /admin/peers returned ${response.status} ${response.statusText}${body ? ` \u2014 ${body}` : ""}`
|
|
4861
|
+
);
|
|
4862
|
+
}
|
|
4863
|
+
} finally {
|
|
4864
|
+
clearTimeout(timer);
|
|
4865
|
+
}
|
|
4866
|
+
}
|
|
4867
|
+
/**
|
|
4868
|
+
* DELETE /admin/peers/:peerId?removeRoutes=true — deregister a child peer.
|
|
4869
|
+
*
|
|
4870
|
+
* Idempotent: a 404 from the connector (peer already removed) is treated as
|
|
4871
|
+
* success so callers can safely use this as a rollback step without knowing
|
|
4872
|
+
* whether the peer was ever registered.
|
|
4873
|
+
*
|
|
4874
|
+
* `removeRoutes=true` is always sent so the connector drops the ILP routing
|
|
4875
|
+
* entries for this peer along with the BTP connection config.
|
|
4876
|
+
*
|
|
4877
|
+
* @throws Error on empty peerId (rejected at client, no network request made)
|
|
4878
|
+
* @throws Error on non-2xx/404 response, timeout, or connection refused
|
|
4879
|
+
*/
|
|
4880
|
+
async removePeer(peerId) {
|
|
4881
|
+
if (!peerId) {
|
|
4882
|
+
throw new Error(
|
|
4883
|
+
"Connector admin API: removePeer requires a non-empty peerId"
|
|
4884
|
+
);
|
|
4885
|
+
}
|
|
4886
|
+
const url = `${this.baseUrl}/admin/peers/${encodeURIComponent(peerId)}?removeRoutes=true`;
|
|
4887
|
+
const controller = new AbortController();
|
|
4888
|
+
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
4889
|
+
try {
|
|
4890
|
+
let response;
|
|
4891
|
+
try {
|
|
4892
|
+
response = await fetch(url, {
|
|
4893
|
+
method: "DELETE",
|
|
4894
|
+
signal: controller.signal
|
|
4895
|
+
});
|
|
4896
|
+
} catch (error) {
|
|
4897
|
+
if (error instanceof Error && error.name === "AbortError") {
|
|
4898
|
+
throw new Error(
|
|
4899
|
+
`Connector admin API request timeout after ${this.timeoutMs}ms: DELETE ${url}`
|
|
4900
|
+
);
|
|
4901
|
+
}
|
|
4902
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
4903
|
+
throw new Error(
|
|
4904
|
+
`Connector admin API request failed: DELETE ${url} \u2014 ${msg}`
|
|
4905
|
+
);
|
|
4906
|
+
}
|
|
4907
|
+
if (response.status === 404) {
|
|
4908
|
+
return;
|
|
4909
|
+
}
|
|
4910
|
+
if (!response.ok) {
|
|
4911
|
+
let body = "";
|
|
4912
|
+
try {
|
|
4913
|
+
body = await response.text();
|
|
4914
|
+
} catch (error) {
|
|
4915
|
+
if (error instanceof Error && error.name === "AbortError") {
|
|
4916
|
+
throw new Error(
|
|
4917
|
+
`Connector admin API request timeout after ${this.timeoutMs}ms: DELETE ${url} (body read)`
|
|
4918
|
+
);
|
|
4919
|
+
}
|
|
4920
|
+
}
|
|
4921
|
+
throw new Error(
|
|
4922
|
+
`Connector admin API error: DELETE /admin/peers/${peerId} returned ${response.status} ${response.statusText}${body ? ` \u2014 ${body}` : ""}`
|
|
4923
|
+
);
|
|
4924
|
+
}
|
|
4925
|
+
} finally {
|
|
4926
|
+
clearTimeout(timer);
|
|
4927
|
+
}
|
|
4928
|
+
}
|
|
4929
|
+
/**
|
|
4930
|
+
* GET /packets — returns the connector's raw packet log filtered by the
|
|
4931
|
+
* given criteria. Used by the timeseries aggregation route (story 21.10).
|
|
4932
|
+
*
|
|
4933
|
+
* Townhouse-Side Contract: see packages/sdk/CONNECTOR_MIGRATION.md §getPacketLog.
|
|
4934
|
+
* If the connector image does not expose GET /packets, this method throws
|
|
4935
|
+
* with a `ConnectorEndpointNotFound` error code so the route can return 503.
|
|
4936
|
+
*
|
|
4937
|
+
* @throws Error with code='ConnectorEndpointNotFound' when connector returns 404
|
|
4938
|
+
* @throws Error when connector is not running, returns non-200, or shape is invalid
|
|
4939
|
+
*/
|
|
4940
|
+
async getPacketLog(filter = {}) {
|
|
4941
|
+
const params = new URLSearchParams();
|
|
4942
|
+
if (filter.ilpAddress !== void 0)
|
|
4943
|
+
params.set("ilpAddress", filter.ilpAddress);
|
|
4944
|
+
if (filter.since !== void 0) params.set("since", String(filter.since));
|
|
4945
|
+
if (filter.limit !== void 0) params.set("limit", String(filter.limit));
|
|
4946
|
+
const path = params.toString() ? `/packets?${params.toString()}` : "/packets";
|
|
4947
|
+
let response;
|
|
4948
|
+
try {
|
|
4949
|
+
response = await this.fetch(path);
|
|
4950
|
+
} catch (error) {
|
|
4951
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
4952
|
+
if (msg.includes("404")) {
|
|
4953
|
+
const err = new Error(
|
|
4954
|
+
"Connector does not expose GET /packets \u2014 endpoint not found"
|
|
4955
|
+
);
|
|
4956
|
+
err.code = "ConnectorEndpointNotFound";
|
|
4957
|
+
throw err;
|
|
4958
|
+
}
|
|
4959
|
+
throw error;
|
|
4960
|
+
}
|
|
4961
|
+
const body = await response.json();
|
|
4962
|
+
if (!Array.isArray(body)) {
|
|
4963
|
+
throw new Error(
|
|
4964
|
+
"Connector admin API: invalid packet log response shape \u2014 expected array"
|
|
4965
|
+
);
|
|
4966
|
+
}
|
|
4967
|
+
return body;
|
|
4968
|
+
}
|
|
4969
|
+
// ── Private helpers ──
|
|
4970
|
+
/**
|
|
4971
|
+
* Perform an HTTP GET request to the connector admin API.
|
|
4972
|
+
* Wraps fetch with error handling for connection refused and non-200 responses.
|
|
4973
|
+
*/
|
|
4974
|
+
async fetch(path) {
|
|
4975
|
+
const url = `${this.baseUrl}${path}`;
|
|
4976
|
+
const controller = new AbortController();
|
|
4977
|
+
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
4978
|
+
try {
|
|
4979
|
+
let response;
|
|
4980
|
+
try {
|
|
4981
|
+
response = await fetch(url, { signal: controller.signal });
|
|
4982
|
+
} catch (error) {
|
|
4983
|
+
if (error instanceof Error && error.name === "AbortError") {
|
|
4984
|
+
throw new Error(
|
|
4985
|
+
`Connector admin API request timeout after ${this.timeoutMs}ms: ${url}`
|
|
4986
|
+
);
|
|
4987
|
+
}
|
|
4988
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
4989
|
+
throw new Error(`Connector admin API connection refused: ${msg}`);
|
|
4990
|
+
}
|
|
4991
|
+
if (!response.ok) {
|
|
4992
|
+
throw new Error(
|
|
4993
|
+
`Connector admin API error: ${response.status} ${response.statusText}`
|
|
4994
|
+
);
|
|
4995
|
+
}
|
|
4996
|
+
return response;
|
|
4997
|
+
} finally {
|
|
4998
|
+
clearTimeout(timer);
|
|
4999
|
+
}
|
|
5000
|
+
}
|
|
5001
|
+
};
|
|
5002
|
+
|
|
5003
|
+
// src/docker/orchestrator.ts
|
|
5004
|
+
import { EventEmitter } from "events";
|
|
5005
|
+
import { spawn } from "child_process";
|
|
5006
|
+
import { existsSync, mkdirSync, writeFileSync as writeFileSync2 } from "fs";
|
|
5007
|
+
import { dirname, isAbsolute, join as join2 } from "path";
|
|
5008
|
+
function runDockerCompose(file, args, options = {}) {
|
|
5009
|
+
const {
|
|
5010
|
+
timeout,
|
|
5011
|
+
maxBuffer = 16 * 1024 * 1024,
|
|
5012
|
+
inheritStdio = false,
|
|
5013
|
+
env
|
|
5014
|
+
} = options;
|
|
5015
|
+
return new Promise((resolve2, reject) => {
|
|
5016
|
+
const child = spawn(file, Array.from(args), {
|
|
5017
|
+
stdio: inheritStdio ? ["ignore", "inherit", "pipe"] : ["ignore", "pipe", "pipe"],
|
|
5018
|
+
...env !== void 0 ? { env } : {}
|
|
5019
|
+
});
|
|
5020
|
+
const stderrChunks = [];
|
|
5021
|
+
const stdoutChunks = [];
|
|
5022
|
+
let stderrLen = 0;
|
|
5023
|
+
let stdoutLen = 0;
|
|
5024
|
+
let timedOut = false;
|
|
5025
|
+
const timer = timeout !== void 0 && timeout > 0 ? setTimeout(() => {
|
|
5026
|
+
timedOut = true;
|
|
5027
|
+
child.kill("SIGTERM");
|
|
5028
|
+
setTimeout(() => {
|
|
5029
|
+
if (!child.killed) child.kill("SIGKILL");
|
|
5030
|
+
}, 5e3).unref();
|
|
5031
|
+
}, timeout) : null;
|
|
5032
|
+
child.stderr?.on("data", (chunk) => {
|
|
5033
|
+
if (stderrLen < maxBuffer) {
|
|
5034
|
+
stderrChunks.push(chunk);
|
|
5035
|
+
stderrLen += chunk.length;
|
|
5036
|
+
}
|
|
5037
|
+
});
|
|
5038
|
+
child.stdout?.on("data", (chunk) => {
|
|
5039
|
+
if (stdoutLen < maxBuffer) {
|
|
5040
|
+
stdoutChunks.push(chunk);
|
|
5041
|
+
stdoutLen += chunk.length;
|
|
5042
|
+
}
|
|
5043
|
+
});
|
|
5044
|
+
child.on("error", (err) => {
|
|
5045
|
+
if (timer) clearTimeout(timer);
|
|
5046
|
+
reject(err);
|
|
5047
|
+
});
|
|
5048
|
+
child.on("close", (code, signal) => {
|
|
5049
|
+
if (timer) clearTimeout(timer);
|
|
5050
|
+
const stderr = Buffer.concat(stderrChunks).toString("utf8");
|
|
5051
|
+
const stdout = Buffer.concat(stdoutChunks).toString("utf8");
|
|
5052
|
+
if (timedOut) {
|
|
5053
|
+
const err2 = new Error(
|
|
5054
|
+
`docker subprocess timed out after ${timeout}ms`
|
|
5055
|
+
);
|
|
5056
|
+
err2.stdout = stdout;
|
|
5057
|
+
err2.stderr = stderr;
|
|
5058
|
+
err2.code = "ETIMEDOUT";
|
|
5059
|
+
err2.signal = signal;
|
|
5060
|
+
return reject(err2);
|
|
5061
|
+
}
|
|
5062
|
+
if (code === 0) {
|
|
5063
|
+
return resolve2({ stdout, stderr });
|
|
5064
|
+
}
|
|
5065
|
+
const err = new Error(
|
|
5066
|
+
`docker subprocess exited with ${code !== null ? `code ${code}` : `signal ${signal}`}`
|
|
5067
|
+
);
|
|
5068
|
+
err.stdout = stdout;
|
|
5069
|
+
err.stderr = stderr;
|
|
5070
|
+
if (code !== null) err.code = code;
|
|
5071
|
+
if (signal !== null) err.signal = signal;
|
|
5072
|
+
reject(err);
|
|
5073
|
+
});
|
|
5074
|
+
});
|
|
5075
|
+
}
|
|
5076
|
+
var TOWN_RELAY_PORT = 7100;
|
|
5077
|
+
var STATS_CACHE_TTL_MS = 5e3;
|
|
5078
|
+
var NETWORK_NAME = "townhouse-net";
|
|
5079
|
+
var DEFAULT_NODE_IMAGES = {
|
|
5080
|
+
town: "toon:town",
|
|
5081
|
+
mill: "toon:mill",
|
|
5082
|
+
dvm: "toon:dvm"
|
|
5083
|
+
};
|
|
5084
|
+
var MAX_START_RETRIES = 3;
|
|
5085
|
+
var CONNECTOR_INTERNAL_PORT = 3e3;
|
|
5086
|
+
var RELAY_ATOR_SIDECAR_IMAGE = "toon:townhouse-ator-sidecar";
|
|
5087
|
+
var RELAY_ATOR_SOCKS_PORT = 9051;
|
|
5088
|
+
var OrchestratorError = class extends Error {
|
|
5089
|
+
service;
|
|
5090
|
+
exitCode;
|
|
5091
|
+
stderr;
|
|
5092
|
+
constructor(message, options = {}) {
|
|
5093
|
+
super(message, options.cause ? { cause: options.cause } : void 0);
|
|
5094
|
+
this.name = "OrchestratorError";
|
|
5095
|
+
if (options.service !== void 0) this.service = options.service;
|
|
5096
|
+
if (options.exitCode !== void 0) this.exitCode = options.exitCode;
|
|
5097
|
+
if (options.stderr !== void 0) this.stderr = options.stderr;
|
|
5098
|
+
}
|
|
5099
|
+
};
|
|
5100
|
+
function redactSecretsInComposeStderr(stderr) {
|
|
5101
|
+
const SECRET_KEYS2 = [
|
|
5102
|
+
"TOWN_SECRET_KEY",
|
|
5103
|
+
"MILL_SECRET_KEY",
|
|
5104
|
+
"DVM_SECRET_KEY",
|
|
5105
|
+
"TOWN_SETTLEMENT_PRIVATE_KEY",
|
|
5106
|
+
"MILL_SETTLEMENT_PRIVATE_KEY",
|
|
5107
|
+
"DVM_SETTLEMENT_PRIVATE_KEY",
|
|
5108
|
+
"MILL_MNEMONIC",
|
|
5109
|
+
"TOWNHOUSE_WALLET_PASSWORD"
|
|
5110
|
+
];
|
|
5111
|
+
const pattern = new RegExp(`(${SECRET_KEYS2.join("|")})=[^\\s"'\\n\\r]+`, "g");
|
|
5112
|
+
return stderr.replace(pattern, "$1=[REDACTED]");
|
|
5113
|
+
}
|
|
5114
|
+
function normalizeImageTag(image) {
|
|
5115
|
+
const lastSlash = image.lastIndexOf("/");
|
|
5116
|
+
const nameAndTag = lastSlash >= 0 ? image.slice(lastSlash + 1) : image;
|
|
5117
|
+
if (nameAndTag.includes(":")) {
|
|
5118
|
+
return image;
|
|
5119
|
+
}
|
|
5120
|
+
return `${image}:latest`;
|
|
5121
|
+
}
|
|
5122
|
+
var DockerOrchestrator = class extends EventEmitter {
|
|
5123
|
+
docker;
|
|
5124
|
+
config;
|
|
5125
|
+
configGenerator;
|
|
5126
|
+
walletManager;
|
|
5127
|
+
activeNodes = [];
|
|
5128
|
+
statsCache = /* @__PURE__ */ new Map();
|
|
5129
|
+
profile;
|
|
5130
|
+
composePath;
|
|
5131
|
+
execFileAsync;
|
|
5132
|
+
adminClientFactory;
|
|
5133
|
+
constructor(docker, config, walletManager, options = {}) {
|
|
5134
|
+
super();
|
|
5135
|
+
this.docker = docker;
|
|
5136
|
+
this.config = config;
|
|
5137
|
+
this.configGenerator = new ConnectorConfigGenerator(config);
|
|
5138
|
+
this.walletManager = walletManager;
|
|
5139
|
+
this.profile = options.profile ?? "dev";
|
|
5140
|
+
const trimmedComposePath = options.composePath?.trim();
|
|
5141
|
+
this.composePath = trimmedComposePath !== void 0 && trimmedComposePath.length > 0 ? trimmedComposePath : void 0;
|
|
5142
|
+
this.execFileAsync = options.execFileAsync ?? runDockerCompose;
|
|
5143
|
+
this.adminClientFactory = options.adminClientFactory ?? ((url, t) => new ConnectorAdminClient(url, t));
|
|
5144
|
+
if (this.profile === "hs" && !this.composePath) {
|
|
5145
|
+
throw new OrchestratorError(
|
|
5146
|
+
`profile: 'hs' requires a non-empty composePath. Pass options.composePath pointing at the rendered HS template (typically the composePath returned by materializeComposeTemplate('hs')).`
|
|
5147
|
+
);
|
|
5148
|
+
}
|
|
5149
|
+
}
|
|
5150
|
+
/**
|
|
5151
|
+
* Orchestrate full startup sequence. Branches on profile:
|
|
5152
|
+
* - 'dev' (default): dockerode-based, preserves existing dev-stack behavior
|
|
5153
|
+
* - 'hs': docker compose subprocess + HS hostname readiness gate
|
|
5154
|
+
*/
|
|
5155
|
+
async up(profiles) {
|
|
5156
|
+
if (this.profile === "hs") {
|
|
5157
|
+
await this.upHs(profiles);
|
|
5158
|
+
this.activeNodes = [...profiles];
|
|
5159
|
+
} else {
|
|
5160
|
+
this.activeNodes = [...profiles];
|
|
5161
|
+
await this.upDev(profiles);
|
|
5162
|
+
}
|
|
5163
|
+
}
|
|
5164
|
+
async upDev(profiles) {
|
|
5165
|
+
await this.ensureNetwork();
|
|
5166
|
+
await this.pullImages(profiles);
|
|
5167
|
+
await this.startConnector();
|
|
5168
|
+
await this.waitForHealth("townhouse-connector");
|
|
5169
|
+
await Promise.all(profiles.map((type) => this.startNode(type)));
|
|
5170
|
+
if (profiles.includes("town") && this.config.transport.relayHiddenService) {
|
|
5171
|
+
await this.startRelayAtorSidecar();
|
|
5172
|
+
}
|
|
5173
|
+
}
|
|
5174
|
+
/**
|
|
5175
|
+
* Narrow `this.composePath` to a definite string. The constructor enforces
|
|
5176
|
+
* this invariant for `profile: 'hs'`; this helper exists so the HS-path
|
|
5177
|
+
* methods don't need a non-null assertion (lint-clean) and so a constructor
|
|
5178
|
+
* regression surfaces as an `OrchestratorError` rather than a `TypeError`.
|
|
5179
|
+
*/
|
|
5180
|
+
requireComposePath() {
|
|
5181
|
+
if (!this.composePath) {
|
|
5182
|
+
throw new OrchestratorError(
|
|
5183
|
+
`internal: composePath unset for HS profile (constructor invariant violated)`
|
|
5184
|
+
);
|
|
5185
|
+
}
|
|
5186
|
+
return this.composePath;
|
|
5187
|
+
}
|
|
5188
|
+
/**
|
|
5189
|
+
* validate that composePath is absolute and exists on disk before
|
|
5190
|
+
* passing it to any subprocess call. Defence-in-depth — callers pass paths
|
|
5191
|
+
* from materializeComposeTemplate so this should never fire in normal use.
|
|
5192
|
+
*/
|
|
5193
|
+
validateComposePath(composePath) {
|
|
5194
|
+
if (!isAbsolute(composePath)) {
|
|
5195
|
+
throw new OrchestratorError(
|
|
5196
|
+
`composePath must be an absolute path, got: ${composePath}`
|
|
5197
|
+
);
|
|
5198
|
+
}
|
|
5199
|
+
if (!existsSync(composePath)) {
|
|
5200
|
+
throw new OrchestratorError(
|
|
5201
|
+
`composePath does not exist on disk: ${composePath}`
|
|
5202
|
+
);
|
|
5203
|
+
}
|
|
5204
|
+
}
|
|
5205
|
+
/** HS-mode startup: shell out to `docker compose up -d`, wait for HS hostname. */
|
|
5206
|
+
async upHs(profiles) {
|
|
5207
|
+
const composePath = this.requireComposePath();
|
|
5208
|
+
this.validateComposePath(composePath);
|
|
5209
|
+
const PROFILE_ORDER = ["town", "mill", "dvm"];
|
|
5210
|
+
for (const p of profiles) {
|
|
5211
|
+
if (!PROFILE_ORDER.includes(p)) {
|
|
5212
|
+
throw new OrchestratorError(
|
|
5213
|
+
`Unknown profile '${String(p)}'. Expected one of: ${PROFILE_ORDER.join(", ")}.`
|
|
5214
|
+
);
|
|
5215
|
+
}
|
|
5216
|
+
}
|
|
5217
|
+
const args = ["compose", "-f", composePath];
|
|
5218
|
+
for (const type of PROFILE_ORDER) {
|
|
5219
|
+
if (profiles.includes(type)) {
|
|
5220
|
+
args.push("--profile", type);
|
|
5221
|
+
}
|
|
5222
|
+
}
|
|
5223
|
+
args.push("up", "-d");
|
|
5224
|
+
try {
|
|
5225
|
+
await this.execFileAsync("docker", args, {
|
|
5226
|
+
timeout: 18e4,
|
|
5227
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
5228
|
+
inheritStdio: true
|
|
5229
|
+
});
|
|
5230
|
+
} catch (err) {
|
|
5231
|
+
const e = err;
|
|
5232
|
+
const stderr = String(e.stderr ?? "");
|
|
5233
|
+
const numericExit = typeof e.code === "number" ? e.code : void 0;
|
|
5234
|
+
const codeLabel = String(e.code ?? e.signal ?? "unknown");
|
|
5235
|
+
let message;
|
|
5236
|
+
if (e.code === "ENOENT") {
|
|
5237
|
+
message = `docker CLI not found on PATH (ENOENT): ${stderr.trim().slice(0, 2e3)}`;
|
|
5238
|
+
} else if (e.code === "ETIMEDOUT") {
|
|
5239
|
+
message = `docker compose up timed out after 180000ms: ${stderr.trim().slice(0, 2e3)}`;
|
|
5240
|
+
} else {
|
|
5241
|
+
message = `docker compose up failed (exit ${codeLabel}): ${stderr.trim().slice(0, 2e3)}`;
|
|
5242
|
+
}
|
|
5243
|
+
this.surfaceComposeFailure(stderr);
|
|
5244
|
+
throw new OrchestratorError(message, {
|
|
5245
|
+
...numericExit !== void 0 ? { exitCode: numericExit } : {},
|
|
5246
|
+
stderr,
|
|
5247
|
+
cause: err instanceof Error ? err : void 0
|
|
5248
|
+
});
|
|
5249
|
+
}
|
|
5250
|
+
try {
|
|
5251
|
+
await this.waitForHsHostname();
|
|
5252
|
+
} catch (err) {
|
|
5253
|
+
await this.downHs().catch(() => {
|
|
5254
|
+
});
|
|
5255
|
+
throw err;
|
|
5256
|
+
}
|
|
5257
|
+
}
|
|
5258
|
+
/**
|
|
5259
|
+
* Parse Docker Compose stderr for failed-service names and emit a
|
|
5260
|
+
* containerState event per failed service so callers see the failure via
|
|
5261
|
+
* the same channel dev-mode uses (AC #6 — "for each failed service
|
|
5262
|
+
* identified, it emits..."). When no pattern matches, emit a single
|
|
5263
|
+
* fallback event with name `'compose-up'`.
|
|
5264
|
+
*/
|
|
5265
|
+
surfaceComposeFailure(stderr) {
|
|
5266
|
+
const patterns = [
|
|
5267
|
+
/failed to start (?:service\s+)?["']([^"']+)["']/gi,
|
|
5268
|
+
/service\s+["']([^"']+)["']\s+failed/gi,
|
|
5269
|
+
/Container\s+[\w-]+-([a-z][\w-]*?)(?:-\d+)?\s+Error/gi
|
|
5270
|
+
];
|
|
5271
|
+
const detail = stderr.trim().slice(0, 2e3);
|
|
5272
|
+
const seen = /* @__PURE__ */ new Set();
|
|
5273
|
+
for (const pattern of patterns) {
|
|
5274
|
+
for (const match of stderr.matchAll(pattern)) {
|
|
5275
|
+
const name = match[1];
|
|
5276
|
+
if (name && !seen.has(name)) {
|
|
5277
|
+
seen.add(name);
|
|
5278
|
+
this.emit("containerState", { name, state: "error", detail });
|
|
5279
|
+
}
|
|
5280
|
+
}
|
|
5281
|
+
}
|
|
5282
|
+
if (seen.size === 0) {
|
|
5283
|
+
this.emit("containerState", {
|
|
5284
|
+
name: "compose-up",
|
|
5285
|
+
state: "error",
|
|
5286
|
+
detail
|
|
5287
|
+
});
|
|
5288
|
+
}
|
|
5289
|
+
}
|
|
5290
|
+
async waitForHsHostname() {
|
|
5291
|
+
const adminUrl = `http://127.0.0.1:${this.config.connector.adminPort}`;
|
|
5292
|
+
const client = this.adminClientFactory(adminUrl, 5e3);
|
|
5293
|
+
const deadlineNs = process.hrtime.bigint() + 120000000000n;
|
|
5294
|
+
const pollInterval = 2e3;
|
|
5295
|
+
let lastResponse;
|
|
5296
|
+
let lastError;
|
|
5297
|
+
while (process.hrtime.bigint() < deadlineNs) {
|
|
5298
|
+
try {
|
|
5299
|
+
lastResponse = await client.getHsHostname();
|
|
5300
|
+
lastError = void 0;
|
|
5301
|
+
if (lastResponse.hostname !== null && lastResponse.publishedAt !== null) {
|
|
5302
|
+
return;
|
|
5303
|
+
}
|
|
5304
|
+
} catch (err) {
|
|
5305
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
5306
|
+
lastError = err instanceof Error ? err : new Error(String(err));
|
|
5307
|
+
if (msg.includes("anon-disabled")) {
|
|
5308
|
+
throw new OrchestratorError(
|
|
5309
|
+
`connector is anon-disabled \u2014 set anon.enabled: true in the connector config`,
|
|
5310
|
+
{ cause: err instanceof Error ? err : void 0 }
|
|
5311
|
+
);
|
|
5312
|
+
}
|
|
5313
|
+
if (msg.includes("invalid hs-hostname response shape") || msg.includes("invalid JSON in hs-hostname response")) {
|
|
5314
|
+
throw new OrchestratorError(
|
|
5315
|
+
`connector returned a malformed /admin/hs-hostname response: ${msg}`,
|
|
5316
|
+
{ cause: err instanceof Error ? err : void 0 }
|
|
5317
|
+
);
|
|
5318
|
+
}
|
|
5319
|
+
if (msg.includes("unexpected status")) {
|
|
5320
|
+
throw new OrchestratorError(msg, {
|
|
5321
|
+
cause: err instanceof Error ? err : void 0
|
|
5322
|
+
});
|
|
5323
|
+
}
|
|
5324
|
+
}
|
|
5325
|
+
await new Promise((r) => setTimeout(r, pollInterval));
|
|
5326
|
+
}
|
|
5327
|
+
const tail = lastError ? ` (last error: ${lastError.message})` : lastResponse ? ` (last response: ${JSON.stringify(lastResponse)})` : " (no successful response received)";
|
|
5328
|
+
throw new OrchestratorError(
|
|
5329
|
+
`HS hostname publication timeout after 120000ms` + tail,
|
|
5330
|
+
lastError ? { cause: lastError } : {}
|
|
5331
|
+
);
|
|
5332
|
+
}
|
|
5333
|
+
/**
|
|
5334
|
+
* Regenerate connector config and restart the connector container
|
|
5335
|
+
* with updated environment variables (peer list).
|
|
5336
|
+
*
|
|
5337
|
+
* Sequence: emit connectorRestarting -> stop -> remove -> create -> start -> health -> emit connectorRestarted
|
|
5338
|
+
*/
|
|
5339
|
+
async regenerateConnectorConfig(activeNodes) {
|
|
5340
|
+
this.activeNodes = [...activeNodes];
|
|
5341
|
+
this.emit("connectorRestarting", { reason: "peer list updated" });
|
|
5342
|
+
const connectorName = `${CONTAINER_PREFIX}connector`;
|
|
5343
|
+
const existingContainer = this.docker.getContainer(connectorName);
|
|
5344
|
+
try {
|
|
5345
|
+
await existingContainer.stop({ t: 5 });
|
|
5346
|
+
} catch {
|
|
5347
|
+
}
|
|
5348
|
+
try {
|
|
5349
|
+
await existingContainer.remove();
|
|
5350
|
+
} catch {
|
|
5351
|
+
}
|
|
5352
|
+
await this.ensureNetwork();
|
|
5353
|
+
try {
|
|
5354
|
+
await this.startConnector();
|
|
5355
|
+
await this.waitForHealth(connectorName);
|
|
5356
|
+
} finally {
|
|
5357
|
+
this.emit("connectorRestarted", { peers: activeNodes });
|
|
5358
|
+
}
|
|
5359
|
+
}
|
|
5360
|
+
/**
|
|
5361
|
+
* Hot-add a node after initial startup.
|
|
5362
|
+
* Starts the node container, then restarts the connector with updated peer list.
|
|
5363
|
+
*/
|
|
5364
|
+
async addNode(type) {
|
|
5365
|
+
if (!this.activeNodes.includes(type)) {
|
|
5366
|
+
this.activeNodes.push(type);
|
|
5367
|
+
}
|
|
5368
|
+
await this.startNode(type);
|
|
5369
|
+
await this.regenerateConnectorConfig(this.activeNodes);
|
|
5370
|
+
}
|
|
5371
|
+
/**
|
|
5372
|
+
* Hot-remove a node.
|
|
5373
|
+
* Stops the node container, then restarts the connector with updated peer list.
|
|
5374
|
+
*/
|
|
5375
|
+
async removeNode(type) {
|
|
5376
|
+
this.activeNodes = this.activeNodes.filter((n) => n !== type);
|
|
5377
|
+
const containerName = `${CONTAINER_PREFIX}${type}`;
|
|
5378
|
+
await this.stopAndRemove(containerName);
|
|
5379
|
+
await this.regenerateConnectorConfig(this.activeNodes);
|
|
5380
|
+
}
|
|
5381
|
+
/**
|
|
5382
|
+
* Graceful shutdown. Branches on profile:
|
|
5383
|
+
* - 'dev' (default): dockerode-based teardown
|
|
5384
|
+
* - 'hs': docker compose subprocess
|
|
5385
|
+
*/
|
|
5386
|
+
async down() {
|
|
5387
|
+
if (this.profile === "hs") {
|
|
5388
|
+
await this.downHs();
|
|
5389
|
+
} else {
|
|
5390
|
+
await this.downDev();
|
|
5391
|
+
}
|
|
5392
|
+
}
|
|
5393
|
+
async downDev() {
|
|
5394
|
+
const containers = await this.docker.listContainers({ all: true });
|
|
5395
|
+
const nodeContainerNames = [];
|
|
5396
|
+
let connectorName;
|
|
5397
|
+
for (const info of containers) {
|
|
5398
|
+
for (const name of info.Names) {
|
|
5399
|
+
const cleanName = name.startsWith("/") ? name.slice(1) : name;
|
|
5400
|
+
if (!cleanName.startsWith(CONTAINER_PREFIX)) continue;
|
|
5401
|
+
if (cleanName === `${CONTAINER_PREFIX}connector`) {
|
|
5402
|
+
connectorName = cleanName;
|
|
5403
|
+
} else {
|
|
5404
|
+
nodeContainerNames.push(cleanName);
|
|
5405
|
+
}
|
|
5406
|
+
}
|
|
5407
|
+
}
|
|
5408
|
+
await Promise.all(
|
|
5409
|
+
nodeContainerNames.map((name) => this.stopAndRemove(name))
|
|
5410
|
+
);
|
|
5411
|
+
if (connectorName) {
|
|
5412
|
+
await this.stopAndRemove(connectorName);
|
|
5413
|
+
}
|
|
5414
|
+
await this.removeNetwork();
|
|
5415
|
+
}
|
|
5416
|
+
async downHs() {
|
|
5417
|
+
const composePath = this.requireComposePath();
|
|
5418
|
+
const args = ["compose", "-f", composePath, "down"];
|
|
5419
|
+
try {
|
|
5420
|
+
await this.execFileAsync("docker", args, {
|
|
5421
|
+
timeout: 12e4,
|
|
5422
|
+
maxBuffer: 16 * 1024 * 1024
|
|
5423
|
+
});
|
|
5424
|
+
} catch (err) {
|
|
5425
|
+
const e = err;
|
|
5426
|
+
const stderr = String(e.stderr ?? "");
|
|
5427
|
+
const numericExit = typeof e.code === "number" ? e.code : void 0;
|
|
5428
|
+
const codeLabel = String(e.code ?? e.signal ?? "unknown");
|
|
5429
|
+
let message;
|
|
5430
|
+
if (e.code === "ENOENT") {
|
|
5431
|
+
message = `docker CLI not found on PATH (ENOENT): ${stderr.trim().slice(0, 2e3)}`;
|
|
5432
|
+
} else if (e.code === "ETIMEDOUT") {
|
|
5433
|
+
message = `docker compose down timed out after 120000ms: ${stderr.trim().slice(0, 2e3)}`;
|
|
5434
|
+
} else {
|
|
5435
|
+
if (stderr.includes("no such service") || stderr.includes("no containers to remove") || stderr.includes("No such container") || stderr.includes("network") && stderr.includes("not found")) {
|
|
5436
|
+
return;
|
|
5437
|
+
}
|
|
5438
|
+
message = `docker compose down failed (exit ${codeLabel}): ${stderr.trim().slice(0, 2e3)}`;
|
|
5439
|
+
}
|
|
5440
|
+
throw new OrchestratorError(message, {
|
|
5441
|
+
...numericExit !== void 0 ? { exitCode: numericExit } : {},
|
|
5442
|
+
stderr,
|
|
5443
|
+
cause: err instanceof Error ? err : void 0
|
|
5444
|
+
});
|
|
5445
|
+
}
|
|
5446
|
+
}
|
|
5447
|
+
/**
|
|
5448
|
+
* Resolve the Nostr relay WebSocket URL for a Town node instance.
|
|
5449
|
+
*
|
|
5450
|
+
* Inspects the container's port bindings to get the host-bound port for
|
|
5451
|
+
* the relay WebSocket (7100/tcp). Falls back to the Docker-internal URL
|
|
5452
|
+
* when the server is running inside the Docker network or bindings are absent.
|
|
5453
|
+
*
|
|
5454
|
+
* @param nodeId - The `NodeInfo.id` value (e.g. 'town', 'dev-town-01')
|
|
5455
|
+
*/
|
|
5456
|
+
async getNodeRelayEndpoint(nodeId) {
|
|
5457
|
+
const containerName = `${CONTAINER_PREFIX}${nodeId}`;
|
|
5458
|
+
try {
|
|
4583
5459
|
const container = this.docker.getContainer(containerName);
|
|
4584
5460
|
const info = await container.inspect();
|
|
4585
5461
|
const portBindings = info.HostConfig?.PortBindings;
|
|
@@ -4739,18 +5615,158 @@ var DockerOrchestrator = class extends EventEmitter {
|
|
|
4739
5615
|
if (profiles.includes("town") && this.config.transport.relayHiddenService) {
|
|
4740
5616
|
imagesToPull.add(normalizeImageTag(RELAY_ATOR_SIDECAR_IMAGE));
|
|
4741
5617
|
}
|
|
5618
|
+
for (const image of imagesToPull) {
|
|
5619
|
+
await this.pullImage(image);
|
|
5620
|
+
}
|
|
5621
|
+
}
|
|
5622
|
+
/**
|
|
5623
|
+
* Pull a single image by its reference (tag or digest form).
|
|
5624
|
+
*
|
|
5625
|
+
* Skips the pull when the image already exists locally (matches against
|
|
5626
|
+
* both RepoTags and RepoDigests so digest-form refs like
|
|
5627
|
+
* `ghcr.io/toon-protocol/town@sha256:abc...` are found correctly).
|
|
5628
|
+
* Throws `OrchestratorError` on pull failure.
|
|
5629
|
+
*/
|
|
5630
|
+
async pullImage(image) {
|
|
4742
5631
|
const existingImages = await this.docker.listImages();
|
|
4743
5632
|
const existingRefs = /* @__PURE__ */ new Set();
|
|
4744
5633
|
for (const img of existingImages) {
|
|
4745
5634
|
for (const tag of img.RepoTags ?? []) existingRefs.add(tag);
|
|
4746
5635
|
for (const digest of img.RepoDigests ?? []) existingRefs.add(digest);
|
|
4747
5636
|
}
|
|
4748
|
-
|
|
4749
|
-
|
|
4750
|
-
|
|
4751
|
-
|
|
5637
|
+
if (existingRefs.has(image)) {
|
|
5638
|
+
return;
|
|
5639
|
+
}
|
|
5640
|
+
try {
|
|
4752
5641
|
const stream = await this.docker.pull(image);
|
|
4753
5642
|
await this.followPullProgress(image, stream);
|
|
5643
|
+
} catch (err) {
|
|
5644
|
+
throw new OrchestratorError(
|
|
5645
|
+
`Failed to pull image ${image}: ${err instanceof Error ? err.message : String(err)}`,
|
|
5646
|
+
{ cause: err instanceof Error ? err : void 0 }
|
|
5647
|
+
);
|
|
5648
|
+
}
|
|
5649
|
+
}
|
|
5650
|
+
/**
|
|
5651
|
+
* Start a child peer node via `docker compose --profile <type> up -d <type>`.
|
|
5652
|
+
*
|
|
5653
|
+
* HS-profile only — throws `OrchestratorError` when called on the dev profile.
|
|
5654
|
+
*
|
|
5655
|
+
* The `env` parameter supplies the per-node wallet secrets (e.g.
|
|
5656
|
+
* `TOWN_SECRET_KEY`, `MILL_MNEMONIC`). It is layered on top of `process.env`
|
|
5657
|
+
* so that PATH, HOME, and other process-level env vars are preserved for the
|
|
5658
|
+
* docker CLI subprocess.
|
|
5659
|
+
*
|
|
5660
|
+
* Logging guard: the caller (nodes-lifecycle route) must NOT log the `env`
|
|
5661
|
+
* argument — it contains secret keys and the wallet mnemonic.
|
|
5662
|
+
*/
|
|
5663
|
+
async startNodeViaCompose(type, env) {
|
|
5664
|
+
if (this.profile === "dev") {
|
|
5665
|
+
throw new OrchestratorError(
|
|
5666
|
+
`startNodeViaCompose is only available in HS profile; current profile is 'dev'`
|
|
5667
|
+
);
|
|
5668
|
+
}
|
|
5669
|
+
const composePath = this.requireComposePath();
|
|
5670
|
+
this.validateComposePath(composePath);
|
|
5671
|
+
const args = [
|
|
5672
|
+
"compose",
|
|
5673
|
+
"-f",
|
|
5674
|
+
composePath,
|
|
5675
|
+
"--profile",
|
|
5676
|
+
type,
|
|
5677
|
+
"up",
|
|
5678
|
+
"-d",
|
|
5679
|
+
type
|
|
5680
|
+
];
|
|
5681
|
+
try {
|
|
5682
|
+
await this.execFileAsync("docker", args, {
|
|
5683
|
+
timeout: 18e4,
|
|
5684
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
5685
|
+
inheritStdio: true,
|
|
5686
|
+
// Layer node secrets on top of process.env — preserves PATH, HOME, etc.
|
|
5687
|
+
env: { ...process.env, ...env }
|
|
5688
|
+
});
|
|
5689
|
+
} catch (err) {
|
|
5690
|
+
const e = err;
|
|
5691
|
+
const stderr = redactSecretsInComposeStderr(String(e.stderr ?? ""));
|
|
5692
|
+
const numericExit = typeof e.code === "number" ? e.code : void 0;
|
|
5693
|
+
const codeLabel = String(e.code ?? e.signal ?? "unknown");
|
|
5694
|
+
let message;
|
|
5695
|
+
if (e.code === "ENOENT") {
|
|
5696
|
+
message = `docker CLI not found on PATH (ENOENT): ${stderr.trim().slice(0, 2e3)}`;
|
|
5697
|
+
} else if (e.code === "ETIMEDOUT") {
|
|
5698
|
+
message = `docker compose up timed out after 180000ms: ${stderr.trim().slice(0, 2e3)}`;
|
|
5699
|
+
} else {
|
|
5700
|
+
message = `docker compose up failed (exit ${codeLabel}): ${stderr.trim().slice(0, 2e3)}`;
|
|
5701
|
+
}
|
|
5702
|
+
this.surfaceComposeFailure(stderr);
|
|
5703
|
+
throw new OrchestratorError(message, {
|
|
5704
|
+
...numericExit !== void 0 ? { exitCode: numericExit } : {},
|
|
5705
|
+
stderr,
|
|
5706
|
+
cause: err instanceof Error ? err : void 0
|
|
5707
|
+
});
|
|
5708
|
+
}
|
|
5709
|
+
}
|
|
5710
|
+
/**
|
|
5711
|
+
* Stop and remove a child peer node via `docker compose stop` + `rm -f`.
|
|
5712
|
+
*
|
|
5713
|
+
* HS-profile only — throws `OrchestratorError` when called on the dev profile.
|
|
5714
|
+
* Idempotent: stderr patterns indicating the service/container is already gone
|
|
5715
|
+
* (`'no such service'`, `'no containers to remove'`, `'No such container'`)
|
|
5716
|
+
* are treated as success so callers can run this as a rollback without
|
|
5717
|
+
* worrying about the container's prior state.
|
|
5718
|
+
*/
|
|
5719
|
+
async stopNodeViaCompose(type) {
|
|
5720
|
+
if (this.profile === "dev") {
|
|
5721
|
+
throw new OrchestratorError(
|
|
5722
|
+
`stopNodeViaCompose is only available in HS profile; current profile is 'dev'`
|
|
5723
|
+
);
|
|
5724
|
+
}
|
|
5725
|
+
const composePath = this.requireComposePath();
|
|
5726
|
+
const idempotentStderr = (stderr) => stderr.includes("no such service") || stderr.includes("no containers to remove") || stderr.includes("No such container");
|
|
5727
|
+
try {
|
|
5728
|
+
await this.execFileAsync(
|
|
5729
|
+
"docker",
|
|
5730
|
+
["compose", "-f", composePath, "--profile", type, "stop", type],
|
|
5731
|
+
{ timeout: 6e4, maxBuffer: 16 * 1024 * 1024 }
|
|
5732
|
+
);
|
|
5733
|
+
} catch (err) {
|
|
5734
|
+
const e = err;
|
|
5735
|
+
const stderr = redactSecretsInComposeStderr(String(e.stderr ?? ""));
|
|
5736
|
+
if (!idempotentStderr(stderr)) {
|
|
5737
|
+
const numericExit = typeof e.code === "number" ? e.code : void 0;
|
|
5738
|
+
const codeLabel = String(e.code ?? "unknown");
|
|
5739
|
+
throw new OrchestratorError(
|
|
5740
|
+
`docker compose stop failed (exit ${codeLabel}): ${stderr.trim().slice(0, 2e3)}`,
|
|
5741
|
+
{
|
|
5742
|
+
...numericExit !== void 0 ? { exitCode: numericExit } : {},
|
|
5743
|
+
stderr,
|
|
5744
|
+
cause: err instanceof Error ? err : void 0
|
|
5745
|
+
}
|
|
5746
|
+
);
|
|
5747
|
+
}
|
|
5748
|
+
}
|
|
5749
|
+
try {
|
|
5750
|
+
await this.execFileAsync(
|
|
5751
|
+
"docker",
|
|
5752
|
+
["compose", "-f", composePath, "--profile", type, "rm", "-f", type],
|
|
5753
|
+
{ timeout: 6e4, maxBuffer: 16 * 1024 * 1024 }
|
|
5754
|
+
);
|
|
5755
|
+
} catch (err) {
|
|
5756
|
+
const e = err;
|
|
5757
|
+
const stderr = redactSecretsInComposeStderr(String(e.stderr ?? ""));
|
|
5758
|
+
if (!idempotentStderr(stderr)) {
|
|
5759
|
+
const numericExit = typeof e.code === "number" ? e.code : void 0;
|
|
5760
|
+
const codeLabel = String(e.code ?? "unknown");
|
|
5761
|
+
throw new OrchestratorError(
|
|
5762
|
+
`docker compose rm failed (exit ${codeLabel}): ${stderr.trim().slice(0, 2e3)}`,
|
|
5763
|
+
{
|
|
5764
|
+
...numericExit !== void 0 ? { exitCode: numericExit } : {},
|
|
5765
|
+
stderr,
|
|
5766
|
+
cause: err instanceof Error ? err : void 0
|
|
5767
|
+
}
|
|
5768
|
+
);
|
|
5769
|
+
}
|
|
4754
5770
|
}
|
|
4755
5771
|
}
|
|
4756
5772
|
/**
|
|
@@ -4783,7 +5799,7 @@ var DockerOrchestrator = class extends EventEmitter {
|
|
|
4783
5799
|
attempt
|
|
4784
5800
|
});
|
|
4785
5801
|
}
|
|
4786
|
-
await new Promise((
|
|
5802
|
+
await new Promise((resolve2) => setTimeout(resolve2, interval));
|
|
4787
5803
|
}
|
|
4788
5804
|
throw new Error(
|
|
4789
5805
|
`Health check timeout: ${containerName} did not become healthy within ${timeout}ms`
|
|
@@ -4874,7 +5890,7 @@ var DockerOrchestrator = class extends EventEmitter {
|
|
|
4874
5890
|
const name = `${CONTAINER_PREFIX}${type}`;
|
|
4875
5891
|
const nodeConfig = this.config.nodes[type];
|
|
4876
5892
|
const image = nodeConfig.image ?? DEFAULT_NODE_IMAGES[type];
|
|
4877
|
-
const env = this.buildNodeEnv(type);
|
|
5893
|
+
const env = await this.buildNodeEnv(type);
|
|
4878
5894
|
let lastError;
|
|
4879
5895
|
for (let attempt = 1; attempt <= MAX_START_RETRIES; attempt++) {
|
|
4880
5896
|
try {
|
|
@@ -4984,244 +6000,112 @@ var DockerOrchestrator = class extends EventEmitter {
|
|
|
4984
6000
|
} catch {
|
|
4985
6001
|
}
|
|
4986
6002
|
}
|
|
4987
|
-
/**
|
|
4988
|
-
* Build environment variables for the connector container.
|
|
4989
|
-
* Delegates to ConnectorConfigGenerator for consistent config generation.
|
|
4990
|
-
*/
|
|
4991
|
-
buildConnectorEnv() {
|
|
4992
|
-
const runtimeConfig = this.configGenerator.generate(this.activeNodes);
|
|
4993
|
-
return this.configGenerator.toEnvArray(runtimeConfig);
|
|
4994
|
-
}
|
|
4995
|
-
/**
|
|
4996
|
-
* Build environment variables for a node container.
|
|
4997
|
-
* If a WalletManager is provided, injects per-node identity keys.
|
|
4998
|
-
*/
|
|
4999
|
-
buildNodeEnv(type) {
|
|
5000
|
-
const connectorUrl = `ws://${CONTAINER_PREFIX}connector:${CONNECTOR_INTERNAL_PORT}`;
|
|
5001
|
-
const env = [`CONNECTOR_URL=${connectorUrl}`];
|
|
5002
|
-
switch (type) {
|
|
5003
|
-
case "town": {
|
|
5004
|
-
const feePerEvent = this.config.nodes.town.feePerEvent;
|
|
5005
|
-
if (feePerEvent !== void 0) {
|
|
5006
|
-
env.push(`FEE_PER_EVENT=${feePerEvent}`);
|
|
5007
|
-
}
|
|
5008
|
-
const relayHs = this.config.transport.relayHiddenService;
|
|
5009
|
-
if (relayHs?.externalUrl) {
|
|
5010
|
-
env.push(`TOON_EXTERNAL_RELAY_URL=${relayHs.externalUrl}`);
|
|
5011
|
-
}
|
|
5012
|
-
break;
|
|
5013
|
-
}
|
|
5014
|
-
case "mill": {
|
|
5015
|
-
const feeBasisPoints = this.config.nodes.mill.feeBasisPoints;
|
|
5016
|
-
if (feeBasisPoints !== void 0) {
|
|
5017
|
-
env.push(`FEE_BASIS_POINTS=${feeBasisPoints}`);
|
|
5018
|
-
}
|
|
5019
|
-
break;
|
|
5020
|
-
}
|
|
5021
|
-
case "dvm": {
|
|
5022
|
-
const feePerJob = this.config.nodes.dvm.feePerJob;
|
|
5023
|
-
if (feePerJob !== void 0) {
|
|
5024
|
-
env.push(`FEE_PER_JOB=${feePerJob}`);
|
|
5025
|
-
}
|
|
5026
|
-
const kindPricing = this.config.nodes.dvm.kindPricing;
|
|
5027
|
-
if (kindPricing) {
|
|
5028
|
-
for (const [kind, value] of Object.entries(kindPricing)) {
|
|
5029
|
-
env.push(`KIND_PRICING_${kind}=${value}`);
|
|
5030
|
-
}
|
|
5031
|
-
}
|
|
5032
|
-
const turboToken = process.env["TURBO_TOKEN"];
|
|
5033
|
-
if (turboToken) {
|
|
5034
|
-
env.push(`TURBO_TOKEN=${turboToken}`);
|
|
5035
|
-
}
|
|
5036
|
-
break;
|
|
5037
|
-
}
|
|
5038
|
-
}
|
|
5039
|
-
if (this.walletManager) {
|
|
5040
|
-
try {
|
|
5041
|
-
const keys = this.walletManager.getNodeKeys(type);
|
|
5042
|
-
env.push(`NODE_NOSTR_PUBKEY=${keys.nostrPubkey}`);
|
|
5043
|
-
env.push(`NODE_EVM_ADDRESS=${keys.evmAddress}`);
|
|
5044
|
-
const secretHex = Buffer.from(keys.nostrSecretKey).toString("hex");
|
|
5045
|
-
env.push(`NODE_NOSTR_SECRET_KEY=${secretHex}`);
|
|
5046
|
-
} catch {
|
|
5047
|
-
}
|
|
5048
|
-
}
|
|
5049
|
-
return env;
|
|
5050
|
-
}
|
|
5051
|
-
/**
|
|
5052
|
-
* Follow a Docker pull stream and emit progress events.
|
|
5053
|
-
*/
|
|
5054
|
-
async followPullProgress(image, stream) {
|
|
5055
|
-
return new Promise((resolve, reject) => {
|
|
5056
|
-
this.docker.modem.followProgress(
|
|
5057
|
-
stream,
|
|
5058
|
-
(err) => {
|
|
5059
|
-
if (err) {
|
|
5060
|
-
reject(err);
|
|
5061
|
-
} else {
|
|
5062
|
-
resolve();
|
|
5063
|
-
}
|
|
5064
|
-
},
|
|
5065
|
-
(event) => {
|
|
5066
|
-
this.emit("pullProgress", {
|
|
5067
|
-
image,
|
|
5068
|
-
status: event.status ?? "",
|
|
5069
|
-
id: event.id,
|
|
5070
|
-
progress: event.progress
|
|
5071
|
-
});
|
|
5072
|
-
}
|
|
5073
|
-
);
|
|
5074
|
-
});
|
|
5075
|
-
}
|
|
5076
|
-
};
|
|
5077
|
-
|
|
5078
|
-
// src/connector/admin-client.ts
|
|
5079
|
-
var DEFAULT_TIMEOUT_MS = 5e3;
|
|
5080
|
-
var ConnectorAdminClient = class {
|
|
5081
|
-
baseUrl;
|
|
5082
|
-
timeoutMs;
|
|
5083
|
-
/**
|
|
5084
|
-
* @param baseUrl - Base URL for the connector admin API (e.g., 'http://localhost:9402')
|
|
5085
|
-
* @param timeoutMs - Request timeout in milliseconds (default: 5000)
|
|
5086
|
-
*/
|
|
5087
|
-
constructor(baseUrl, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
5088
|
-
this.baseUrl = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
|
|
5089
|
-
this.timeoutMs = timeoutMs;
|
|
5090
|
-
}
|
|
5091
|
-
/**
|
|
5092
|
-
* GET /health — returns the connector's HealthStatus from the healthCheckPort server.
|
|
5093
|
-
*
|
|
5094
|
-
* @throws Error when connector is not running, returns non-200, or shape is invalid
|
|
5095
|
-
*/
|
|
5096
|
-
async getHealth() {
|
|
5097
|
-
const response = await this.fetch("/health");
|
|
5098
|
-
const body = await response.json();
|
|
5099
|
-
if (typeof body !== "object" || body === null) {
|
|
5100
|
-
throw new Error("Connector admin API: invalid health response shape");
|
|
5101
|
-
}
|
|
5102
|
-
const obj = body;
|
|
5103
|
-
const status = obj["status"];
|
|
5104
|
-
if (status !== "healthy" && status !== "unhealthy" && status !== "starting" && status !== "degraded") {
|
|
5105
|
-
throw new Error("Connector admin API: invalid health response shape");
|
|
5106
|
-
}
|
|
5107
|
-
if (typeof obj["uptime"] !== "number" || typeof obj["peersConnected"] !== "number" || typeof obj["totalPeers"] !== "number" || typeof obj["timestamp"] !== "string") {
|
|
5108
|
-
throw new Error("Connector admin API: invalid health response shape");
|
|
5109
|
-
}
|
|
5110
|
-
return body;
|
|
5111
|
-
}
|
|
5112
|
-
/**
|
|
5113
|
-
* GET /admin/metrics.json — returns the connector's per-peer ILP counters
|
|
5114
|
-
* with an aggregate rollup, mirroring `AdminMetricsJsonResponse`.
|
|
5115
|
-
*
|
|
5116
|
-
* @throws Error when connector is not running, returns non-200, or shape is invalid
|
|
5117
|
-
*/
|
|
5118
|
-
async getMetrics() {
|
|
5119
|
-
const response = await this.fetch("/admin/metrics.json");
|
|
5120
|
-
const body = await response.json();
|
|
5121
|
-
if (typeof body !== "object" || body === null) {
|
|
5122
|
-
throw new Error("Connector admin API: invalid metrics response shape");
|
|
5123
|
-
}
|
|
5124
|
-
const obj = body;
|
|
5125
|
-
const aggregate = obj["aggregate"];
|
|
5126
|
-
if (typeof obj["uptimeSeconds"] !== "number" || typeof aggregate !== "object" || aggregate === null || !Array.isArray(obj["peers"]) || typeof obj["timestamp"] !== "string") {
|
|
5127
|
-
throw new Error("Connector admin API: invalid metrics response shape");
|
|
5128
|
-
}
|
|
5129
|
-
const agg = aggregate;
|
|
5130
|
-
if (typeof agg["packetsForwarded"] !== "number" || typeof agg["packetsRejected"] !== "number" || typeof agg["bytesSent"] !== "number") {
|
|
5131
|
-
throw new Error("Connector admin API: invalid metrics response shape");
|
|
5132
|
-
}
|
|
5133
|
-
return body;
|
|
5134
|
-
}
|
|
5135
|
-
/**
|
|
5136
|
-
* GET /admin/peers — returns the connector's peer roster with route counts
|
|
5137
|
-
* and ILP addresses. Returns the unwrapped peers array (the wrapper's
|
|
5138
|
-
* nodeId / peerCount / connectedCount fields are dropped).
|
|
5139
|
-
*
|
|
5140
|
-
* @throws Error when connector is not running, returns non-200, or shape is invalid
|
|
5141
|
-
*/
|
|
5142
|
-
async getPeers() {
|
|
5143
|
-
const response = await this.fetch("/admin/peers");
|
|
5144
|
-
const body = await response.json();
|
|
5145
|
-
if (typeof body !== "object" || body === null) {
|
|
5146
|
-
throw new Error("Connector admin API: invalid peers response shape");
|
|
5147
|
-
}
|
|
5148
|
-
const obj = body;
|
|
5149
|
-
if (!Array.isArray(obj["peers"])) {
|
|
5150
|
-
throw new Error("Connector admin API: invalid peers response shape");
|
|
5151
|
-
}
|
|
5152
|
-
return body.peers;
|
|
5153
|
-
}
|
|
5154
|
-
/**
|
|
5155
|
-
* GET /packets — returns the connector's raw packet log filtered by the
|
|
5156
|
-
* given criteria. Used by the timeseries aggregation route (story 21.10).
|
|
5157
|
-
*
|
|
5158
|
-
* Townhouse-Side Contract: see packages/sdk/CONNECTOR_MIGRATION.md §getPacketLog.
|
|
5159
|
-
* If the connector image does not expose GET /packets, this method throws
|
|
5160
|
-
* with a `ConnectorEndpointNotFound` error code so the route can return 503.
|
|
5161
|
-
*
|
|
5162
|
-
* @throws Error with code='ConnectorEndpointNotFound' when connector returns 404
|
|
5163
|
-
* @throws Error when connector is not running, returns non-200, or shape is invalid
|
|
5164
|
-
*/
|
|
5165
|
-
async getPacketLog(filter = {}) {
|
|
5166
|
-
const params = new URLSearchParams();
|
|
5167
|
-
if (filter.ilpAddress !== void 0)
|
|
5168
|
-
params.set("ilpAddress", filter.ilpAddress);
|
|
5169
|
-
if (filter.since !== void 0) params.set("since", String(filter.since));
|
|
5170
|
-
if (filter.limit !== void 0) params.set("limit", String(filter.limit));
|
|
5171
|
-
const path = params.toString() ? `/packets?${params.toString()}` : "/packets";
|
|
5172
|
-
let response;
|
|
5173
|
-
try {
|
|
5174
|
-
response = await this.fetch(path);
|
|
5175
|
-
} catch (error) {
|
|
5176
|
-
const msg = error instanceof Error ? error.message : String(error);
|
|
5177
|
-
if (msg.includes("404")) {
|
|
5178
|
-
const err = new Error(
|
|
5179
|
-
"Connector does not expose GET /packets \u2014 endpoint not found"
|
|
5180
|
-
);
|
|
5181
|
-
err.code = "ConnectorEndpointNotFound";
|
|
5182
|
-
throw err;
|
|
5183
|
-
}
|
|
5184
|
-
throw error;
|
|
5185
|
-
}
|
|
5186
|
-
const body = await response.json();
|
|
5187
|
-
if (!Array.isArray(body)) {
|
|
5188
|
-
throw new Error(
|
|
5189
|
-
"Connector admin API: invalid packet log response shape \u2014 expected array"
|
|
5190
|
-
);
|
|
5191
|
-
}
|
|
5192
|
-
return body;
|
|
6003
|
+
/**
|
|
6004
|
+
* Build environment variables for the connector container.
|
|
6005
|
+
* Delegates to ConnectorConfigGenerator for consistent config generation.
|
|
6006
|
+
*/
|
|
6007
|
+
buildConnectorEnv() {
|
|
6008
|
+
const runtimeConfig = this.configGenerator.generate(this.activeNodes);
|
|
6009
|
+
return this.configGenerator.toEnvArray(runtimeConfig);
|
|
5193
6010
|
}
|
|
5194
|
-
// ── Private helpers ──
|
|
5195
6011
|
/**
|
|
5196
|
-
*
|
|
5197
|
-
*
|
|
6012
|
+
* Build environment variables for a node container.
|
|
6013
|
+
* If a WalletManager is provided, injects per-node identity keys.
|
|
6014
|
+
*
|
|
6015
|
+
* Async because the DVM path may need to derive an RSA-4096 Arweave key
|
|
6016
|
+
* via `walletManager.ensureArweaveKey('dvm')` — that derivation takes
|
|
6017
|
+
* 5–30s on first call per unlocked wallet (cached thereafter).
|
|
5198
6018
|
*/
|
|
5199
|
-
async
|
|
5200
|
-
const
|
|
5201
|
-
const
|
|
5202
|
-
|
|
5203
|
-
|
|
5204
|
-
|
|
5205
|
-
|
|
5206
|
-
|
|
5207
|
-
} catch (error) {
|
|
5208
|
-
if (error instanceof Error && error.name === "AbortError") {
|
|
5209
|
-
throw new Error(
|
|
5210
|
-
`Connector admin API request timeout after ${this.timeoutMs}ms: ${url}`
|
|
5211
|
-
);
|
|
6019
|
+
async buildNodeEnv(type) {
|
|
6020
|
+
const connectorUrl = `ws://${CONTAINER_PREFIX}connector:${CONNECTOR_INTERNAL_PORT}`;
|
|
6021
|
+
const env = [`CONNECTOR_URL=${connectorUrl}`];
|
|
6022
|
+
switch (type) {
|
|
6023
|
+
case "town": {
|
|
6024
|
+
const feePerEvent = this.config.nodes.town.feePerEvent;
|
|
6025
|
+
if (feePerEvent !== void 0) {
|
|
6026
|
+
env.push(`FEE_PER_EVENT=${feePerEvent}`);
|
|
5212
6027
|
}
|
|
5213
|
-
const
|
|
5214
|
-
|
|
6028
|
+
const relayHs = this.config.transport.relayHiddenService;
|
|
6029
|
+
if (relayHs?.externalUrl) {
|
|
6030
|
+
env.push(`TOON_EXTERNAL_RELAY_URL=${relayHs.externalUrl}`);
|
|
6031
|
+
}
|
|
6032
|
+
break;
|
|
5215
6033
|
}
|
|
5216
|
-
|
|
5217
|
-
|
|
5218
|
-
|
|
5219
|
-
|
|
6034
|
+
case "mill": {
|
|
6035
|
+
const feeBasisPoints = this.config.nodes.mill.feeBasisPoints;
|
|
6036
|
+
if (feeBasisPoints !== void 0) {
|
|
6037
|
+
env.push(`FEE_BASIS_POINTS=${feeBasisPoints}`);
|
|
6038
|
+
}
|
|
6039
|
+
break;
|
|
6040
|
+
}
|
|
6041
|
+
case "dvm": {
|
|
6042
|
+
const feePerJob = this.config.nodes.dvm.feePerJob;
|
|
6043
|
+
if (feePerJob !== void 0) {
|
|
6044
|
+
env.push(`FEE_PER_JOB=${feePerJob}`);
|
|
6045
|
+
}
|
|
6046
|
+
const kindPricing = this.config.nodes.dvm.kindPricing;
|
|
6047
|
+
if (kindPricing) {
|
|
6048
|
+
for (const [kind, value] of Object.entries(kindPricing)) {
|
|
6049
|
+
env.push(`KIND_PRICING_${kind}=${value}`);
|
|
6050
|
+
}
|
|
6051
|
+
}
|
|
6052
|
+
if (this.walletManager) {
|
|
6053
|
+
try {
|
|
6054
|
+
console.log(
|
|
6055
|
+
"[orchestrator] Deriving DVM Arweave key (first boot, this can take 5-30s)..."
|
|
6056
|
+
);
|
|
6057
|
+
await this.walletManager.ensureArweaveKey("dvm");
|
|
6058
|
+
const jwk = this.walletManager.getArweaveJwk("dvm");
|
|
6059
|
+
const jwkB64 = Buffer.from(JSON.stringify(jwk), "utf-8").toString(
|
|
6060
|
+
"base64"
|
|
6061
|
+
);
|
|
6062
|
+
env.push(`DVM_ARWEAVE_JWK_B64=${jwkB64}`);
|
|
6063
|
+
} catch {
|
|
6064
|
+
}
|
|
6065
|
+
}
|
|
6066
|
+
const turboToken = process.env["TURBO_TOKEN"];
|
|
6067
|
+
if (turboToken) {
|
|
6068
|
+
env.push(`TURBO_TOKEN=${turboToken}`);
|
|
6069
|
+
}
|
|
6070
|
+
break;
|
|
6071
|
+
}
|
|
6072
|
+
}
|
|
6073
|
+
if (this.walletManager) {
|
|
6074
|
+
try {
|
|
6075
|
+
const keys = this.walletManager.getNodeKeys(type);
|
|
6076
|
+
env.push(`NODE_NOSTR_PUBKEY=${keys.nostrPubkey}`);
|
|
6077
|
+
env.push(`NODE_EVM_ADDRESS=${keys.evmAddress}`);
|
|
6078
|
+
const secretHex = Buffer.from(keys.nostrSecretKey).toString("hex");
|
|
6079
|
+
env.push(`NODE_NOSTR_SECRET_KEY=${secretHex}`);
|
|
6080
|
+
} catch {
|
|
5220
6081
|
}
|
|
5221
|
-
return response;
|
|
5222
|
-
} finally {
|
|
5223
|
-
clearTimeout(timer);
|
|
5224
6082
|
}
|
|
6083
|
+
return env;
|
|
6084
|
+
}
|
|
6085
|
+
/**
|
|
6086
|
+
* Follow a Docker pull stream and emit progress events.
|
|
6087
|
+
*/
|
|
6088
|
+
async followPullProgress(image, stream) {
|
|
6089
|
+
return new Promise((resolve2, reject) => {
|
|
6090
|
+
this.docker.modem.followProgress(
|
|
6091
|
+
stream,
|
|
6092
|
+
(err) => {
|
|
6093
|
+
if (err) {
|
|
6094
|
+
reject(err);
|
|
6095
|
+
} else {
|
|
6096
|
+
resolve2();
|
|
6097
|
+
}
|
|
6098
|
+
},
|
|
6099
|
+
(event) => {
|
|
6100
|
+
this.emit("pullProgress", {
|
|
6101
|
+
image,
|
|
6102
|
+
status: event.status ?? "",
|
|
6103
|
+
id: event.id,
|
|
6104
|
+
progress: event.progress
|
|
6105
|
+
});
|
|
6106
|
+
}
|
|
6107
|
+
);
|
|
6108
|
+
});
|
|
5225
6109
|
}
|
|
5226
6110
|
};
|
|
5227
6111
|
|
|
@@ -5340,7 +6224,7 @@ var TransportProbe = class {
|
|
|
5340
6224
|
this.logTransition(prev, this.status, hostPort);
|
|
5341
6225
|
}
|
|
5342
6226
|
probeTcp(host, port) {
|
|
5343
|
-
return new Promise((
|
|
6227
|
+
return new Promise((resolve2) => {
|
|
5344
6228
|
const start = Date.now();
|
|
5345
6229
|
const socket = net.createConnection({ host, port });
|
|
5346
6230
|
let settled = false;
|
|
@@ -5351,7 +6235,7 @@ var TransportProbe = class {
|
|
|
5351
6235
|
socket.destroy();
|
|
5352
6236
|
} catch {
|
|
5353
6237
|
}
|
|
5354
|
-
|
|
6238
|
+
resolve2(result);
|
|
5355
6239
|
};
|
|
5356
6240
|
const timeout = setTimeout(() => {
|
|
5357
6241
|
settle({ reachable: false, latencyMs: null, error: "timeout" });
|
|
@@ -5371,13 +6255,13 @@ var TransportProbe = class {
|
|
|
5371
6255
|
});
|
|
5372
6256
|
}
|
|
5373
6257
|
probeDirectLatency() {
|
|
5374
|
-
return new Promise((
|
|
6258
|
+
return new Promise((resolve2) => {
|
|
5375
6259
|
const start = Date.now();
|
|
5376
6260
|
let settled = false;
|
|
5377
6261
|
const settle = (ms) => {
|
|
5378
6262
|
if (settled) return;
|
|
5379
6263
|
settled = true;
|
|
5380
|
-
|
|
6264
|
+
resolve2(ms);
|
|
5381
6265
|
};
|
|
5382
6266
|
const isHttps = this.directProbeUrl.startsWith("https://");
|
|
5383
6267
|
const requester = isHttps ? https : http;
|
|
@@ -5404,35 +6288,598 @@ var TransportProbe = class {
|
|
|
5404
6288
|
console.debug(
|
|
5405
6289
|
`[TransportProbe] direct latency probe failed: ${err.code ?? err.message}`
|
|
5406
6290
|
);
|
|
5407
|
-
settle(null);
|
|
6291
|
+
settle(null);
|
|
6292
|
+
});
|
|
6293
|
+
req.end();
|
|
6294
|
+
} catch (err) {
|
|
6295
|
+
clearTimeout(timeout);
|
|
6296
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
6297
|
+
console.debug(`[TransportProbe] direct latency probe threw: ${msg}`);
|
|
6298
|
+
settle(null);
|
|
6299
|
+
}
|
|
6300
|
+
});
|
|
6301
|
+
}
|
|
6302
|
+
logTransition(prev, next, hostPort) {
|
|
6303
|
+
if (prev.lastProbedAt === 0) return;
|
|
6304
|
+
const target = hostPort ? ` (${hostPort})` : "";
|
|
6305
|
+
if (prev.reachable && !next.reachable) {
|
|
6306
|
+
console.warn(
|
|
6307
|
+
`[TransportProbe] proxy became unreachable${target}: ${next.probeError ?? "unknown"}`
|
|
6308
|
+
);
|
|
6309
|
+
} else if (!prev.reachable && next.reachable) {
|
|
6310
|
+
console.debug(`[TransportProbe] proxy reachable${target}`);
|
|
6311
|
+
}
|
|
6312
|
+
}
|
|
6313
|
+
};
|
|
6314
|
+
|
|
6315
|
+
// src/connector/hs-config-writer.ts
|
|
6316
|
+
import { existsSync as existsSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync3, chmodSync } from "fs";
|
|
6317
|
+
import { join as join3 } from "path";
|
|
6318
|
+
import { parse as parse2, stringify as yamlStringify2 } from "yaml";
|
|
6319
|
+
var HS_DIR = "/var/lib/anon/hs";
|
|
6320
|
+
var HS_PORT = 3e3;
|
|
6321
|
+
function writeHsConnectorConfig(configDir, config, options = {}) {
|
|
6322
|
+
const yamlPath = join3(configDir, "connector.yaml");
|
|
6323
|
+
if (!options.force && existsSync2(yamlPath)) {
|
|
6324
|
+
try {
|
|
6325
|
+
const existing = parse2(readFileSync2(yamlPath, "utf-8"));
|
|
6326
|
+
const anon = existing["anon"];
|
|
6327
|
+
if (anon?.["enabled"] === true) {
|
|
6328
|
+
return { yamlPath, created: false };
|
|
6329
|
+
}
|
|
6330
|
+
} catch {
|
|
6331
|
+
}
|
|
6332
|
+
}
|
|
6333
|
+
const hsConfig = config.chainProviders !== void 0 && config.chainProviders.length > 0 ? config : { ...config, chainProviders: [...DEFAULT_HS_CHAIN_PROVIDERS] };
|
|
6334
|
+
const generator = new ConnectorConfigGenerator(hsConfig);
|
|
6335
|
+
const baseConfig = generator.generate([]);
|
|
6336
|
+
const HS_LOCAL_SOCKS_PROXY = "socks5h://127.0.0.1:9050";
|
|
6337
|
+
const hsRuntimeConfig = {
|
|
6338
|
+
...baseConfig,
|
|
6339
|
+
transport: {
|
|
6340
|
+
mode: "ator",
|
|
6341
|
+
socksProxy: HS_LOCAL_SOCKS_PROXY,
|
|
6342
|
+
externalUrl: "auto",
|
|
6343
|
+
hiddenService: {
|
|
6344
|
+
dir: HS_DIR,
|
|
6345
|
+
port: HS_PORT,
|
|
6346
|
+
// The orchestrator polls getHsHostname() for up to 120s; give the
|
|
6347
|
+
// connector the same budget so the internal timeout doesn't fire first.
|
|
6348
|
+
startupTimeoutMs: 12e4
|
|
6349
|
+
}
|
|
6350
|
+
}
|
|
6351
|
+
};
|
|
6352
|
+
const baseYaml = generator.toYaml(hsRuntimeConfig);
|
|
6353
|
+
const parsed = parse2(baseYaml);
|
|
6354
|
+
parsed["anon"] = { enabled: true };
|
|
6355
|
+
const finalYaml = yamlStringify2(parsed);
|
|
6356
|
+
writeFileSync3(yamlPath, finalYaml, { mode: 384, encoding: "utf-8" });
|
|
6357
|
+
chmodSync(yamlPath, 384);
|
|
6358
|
+
return { yamlPath, created: true };
|
|
6359
|
+
}
|
|
6360
|
+
|
|
6361
|
+
// src/compose-loader.ts
|
|
6362
|
+
import {
|
|
6363
|
+
readFileSync as readFileSync3,
|
|
6364
|
+
writeFileSync as writeFileSync4,
|
|
6365
|
+
mkdirSync as mkdirSync2,
|
|
6366
|
+
chmodSync as chmodSync2,
|
|
6367
|
+
statSync,
|
|
6368
|
+
lstatSync,
|
|
6369
|
+
existsSync as existsSync3
|
|
6370
|
+
} from "fs";
|
|
6371
|
+
import { dirname as dirname2, join as join4, resolve, isAbsolute as isAbsolute2 } from "path";
|
|
6372
|
+
import { fileURLToPath } from "url";
|
|
6373
|
+
import { homedir as homedir2 } from "os";
|
|
6374
|
+
var VALID_PROFILES = ["dev", "hs"];
|
|
6375
|
+
var ComposeLoaderError = class extends Error {
|
|
6376
|
+
constructor(message) {
|
|
6377
|
+
super(message);
|
|
6378
|
+
this.name = "ComposeLoaderError";
|
|
6379
|
+
}
|
|
6380
|
+
};
|
|
6381
|
+
function defaultDistDir() {
|
|
6382
|
+
const here = dirname2(fileURLToPath(import.meta.url));
|
|
6383
|
+
return resolve(here, "..", "dist");
|
|
6384
|
+
}
|
|
6385
|
+
function assertValidProfile(profile) {
|
|
6386
|
+
if (!VALID_PROFILES.includes(profile)) {
|
|
6387
|
+
throw new ComposeLoaderError(
|
|
6388
|
+
`invalid compose profile: '${profile}'. Must be one of: ${VALID_PROFILES.join(", ")}.`
|
|
6389
|
+
);
|
|
6390
|
+
}
|
|
6391
|
+
}
|
|
6392
|
+
var SYSTEM_PATH_PREFIXES = [
|
|
6393
|
+
"/etc",
|
|
6394
|
+
"/usr",
|
|
6395
|
+
"/bin",
|
|
6396
|
+
"/sbin",
|
|
6397
|
+
"/lib",
|
|
6398
|
+
"/lib64",
|
|
6399
|
+
"/proc",
|
|
6400
|
+
"/sys",
|
|
6401
|
+
"/dev",
|
|
6402
|
+
"/boot",
|
|
6403
|
+
"/root"
|
|
6404
|
+
];
|
|
6405
|
+
function assertValidTownhouseHome(home) {
|
|
6406
|
+
if (!home) {
|
|
6407
|
+
throw new ComposeLoaderError(
|
|
6408
|
+
"townhouseHome resolved to an empty path. Set $HOME or pass options.townhouseHome explicitly."
|
|
6409
|
+
);
|
|
6410
|
+
}
|
|
6411
|
+
if (!isAbsolute2(home)) {
|
|
6412
|
+
throw new ComposeLoaderError(
|
|
6413
|
+
`townhouseHome must be an absolute path; got '${home}'.`
|
|
6414
|
+
);
|
|
6415
|
+
}
|
|
6416
|
+
if (home === "/" || home === "\\") {
|
|
6417
|
+
throw new ComposeLoaderError(
|
|
6418
|
+
`townhouseHome must not be the filesystem root; got '${home}'. This usually means $HOME is unset and homedir() returned '/'.`
|
|
6419
|
+
);
|
|
6420
|
+
}
|
|
6421
|
+
for (const prefix of SYSTEM_PATH_PREFIXES) {
|
|
6422
|
+
if (home === prefix || home.startsWith(prefix + "/")) {
|
|
6423
|
+
throw new ComposeLoaderError(
|
|
6424
|
+
`townhouseHome must not target a system directory; got '${home}'. Allowed paths: under $HOME, under tmpdir(), or any user-writable location.`
|
|
6425
|
+
);
|
|
6426
|
+
}
|
|
6427
|
+
}
|
|
6428
|
+
}
|
|
6429
|
+
function assertNotSymlink(filePath) {
|
|
6430
|
+
try {
|
|
6431
|
+
const lst = lstatSync(filePath);
|
|
6432
|
+
if (lst.isSymbolicLink()) {
|
|
6433
|
+
throw new ComposeLoaderError(
|
|
6434
|
+
`${filePath} is a symlink; refusing to write through it. If this is intentional, remove the symlink and re-run.`
|
|
6435
|
+
);
|
|
6436
|
+
}
|
|
6437
|
+
} catch (err) {
|
|
6438
|
+
const code = err.code;
|
|
6439
|
+
if (code !== "ENOENT") throw err;
|
|
6440
|
+
}
|
|
6441
|
+
}
|
|
6442
|
+
function loadComposeTemplate(profile, options = {}) {
|
|
6443
|
+
assertValidProfile(profile);
|
|
6444
|
+
const distDir = options.distDir ?? defaultDistDir();
|
|
6445
|
+
const composePath = join4(distDir, "compose", `townhouse-${profile}.yml`);
|
|
6446
|
+
if (!existsSync3(composePath)) {
|
|
6447
|
+
throw new ComposeLoaderError(
|
|
6448
|
+
`compose template not found: ${composePath}. Did you run 'pnpm --filter @toon-protocol/townhouse build' first?`
|
|
6449
|
+
);
|
|
6450
|
+
}
|
|
6451
|
+
return readFileSync3(composePath, "utf-8");
|
|
6452
|
+
}
|
|
6453
|
+
function materializeComposeTemplate(profile, options = {}) {
|
|
6454
|
+
assertValidProfile(profile);
|
|
6455
|
+
const home = options.townhouseHome || join4(homedir2(), ".townhouse");
|
|
6456
|
+
assertValidTownhouseHome(home);
|
|
6457
|
+
const distDir = options.distDir ?? defaultDistDir();
|
|
6458
|
+
const manifestSrc = join4(distDir, "image-manifest.json");
|
|
6459
|
+
if (profile === "hs" && !existsSync3(manifestSrc)) {
|
|
6460
|
+
throw new ComposeLoaderError(
|
|
6461
|
+
`image-manifest.json not found at ${manifestSrc}. HS mode requires a digest-pinned image manifest. Reinstall @toon-protocol/townhouse from npm to restore the manifest.`
|
|
6462
|
+
);
|
|
6463
|
+
}
|
|
6464
|
+
const yaml = loadComposeTemplate(profile, options);
|
|
6465
|
+
const composeDir = join4(home, "compose");
|
|
6466
|
+
mkdirSync2(composeDir, { recursive: true, mode: 448 });
|
|
6467
|
+
for (const dir of [home, composeDir]) {
|
|
6468
|
+
const lst = lstatSync(dir);
|
|
6469
|
+
if (lst.isSymbolicLink()) {
|
|
6470
|
+
const target = statSync(dir);
|
|
6471
|
+
if (!target.isDirectory()) {
|
|
6472
|
+
throw new ComposeLoaderError(
|
|
6473
|
+
`${dir} is a symlink to a non-directory; refusing to materialize.`
|
|
6474
|
+
);
|
|
6475
|
+
}
|
|
6476
|
+
continue;
|
|
6477
|
+
}
|
|
6478
|
+
const currentMode = lst.mode & 511;
|
|
6479
|
+
if ((currentMode & 63) !== 0) {
|
|
6480
|
+
chmodSync2(dir, 448);
|
|
6481
|
+
}
|
|
6482
|
+
}
|
|
6483
|
+
const composePath = join4(composeDir, `townhouse-${profile}.yml`);
|
|
6484
|
+
assertNotSymlink(composePath);
|
|
6485
|
+
writeFileSync4(composePath, yaml, { mode: 384, encoding: "utf-8" });
|
|
6486
|
+
chmodSync2(composePath, 384);
|
|
6487
|
+
const manifestPath = join4(home, "image-manifest.json");
|
|
6488
|
+
if (existsSync3(manifestSrc)) {
|
|
6489
|
+
assertNotSymlink(manifestPath);
|
|
6490
|
+
const manifest = readFileSync3(manifestSrc, "utf-8");
|
|
6491
|
+
writeFileSync4(manifestPath, manifest, { mode: 384, encoding: "utf-8" });
|
|
6492
|
+
chmodSync2(manifestPath, 384);
|
|
6493
|
+
}
|
|
6494
|
+
return { composePath, manifestPath };
|
|
6495
|
+
}
|
|
6496
|
+
|
|
6497
|
+
// src/state/nodes-yaml.ts
|
|
6498
|
+
import { promises as fs } from "fs";
|
|
6499
|
+
import { dirname as dirname3 } from "path";
|
|
6500
|
+
import { parse as yamlParse, stringify as yamlStringify3 } from "yaml";
|
|
6501
|
+
import { z } from "zod";
|
|
6502
|
+
var NodesYamlEntrySchema = z.object({
|
|
6503
|
+
id: z.string().min(1),
|
|
6504
|
+
type: z.enum(["town", "mill", "dvm"]),
|
|
6505
|
+
peerId: z.string().min(1),
|
|
6506
|
+
ilpAddress: z.string().min(1),
|
|
6507
|
+
derivationIndex: z.number().int().nonnegative(),
|
|
6508
|
+
enabledAt: z.string().datetime({ offset: true }),
|
|
6509
|
+
lastSeenAt: z.string().datetime({ offset: true }).nullable()
|
|
6510
|
+
}).strict();
|
|
6511
|
+
var NodesYamlSchema = z.object({
|
|
6512
|
+
entries: z.array(NodesYamlEntrySchema)
|
|
6513
|
+
}).strict().superRefine((data, ctx) => {
|
|
6514
|
+
const seenPeerIds = /* @__PURE__ */ new Set();
|
|
6515
|
+
const seenDerivationIndexes = /* @__PURE__ */ new Set();
|
|
6516
|
+
for (const [i, e] of data.entries.entries()) {
|
|
6517
|
+
if (seenPeerIds.has(e.peerId)) {
|
|
6518
|
+
ctx.addIssue({
|
|
6519
|
+
code: z.ZodIssueCode.custom,
|
|
6520
|
+
path: ["entries", i, "peerId"],
|
|
6521
|
+
message: `duplicate peerId: ${e.peerId}`
|
|
6522
|
+
});
|
|
6523
|
+
}
|
|
6524
|
+
seenPeerIds.add(e.peerId);
|
|
6525
|
+
if (seenDerivationIndexes.has(e.derivationIndex)) {
|
|
6526
|
+
ctx.addIssue({
|
|
6527
|
+
code: z.ZodIssueCode.custom,
|
|
6528
|
+
path: ["entries", i, "derivationIndex"],
|
|
6529
|
+
message: `duplicate derivationIndex: ${e.derivationIndex}`
|
|
6530
|
+
});
|
|
6531
|
+
}
|
|
6532
|
+
seenDerivationIndexes.add(e.derivationIndex);
|
|
6533
|
+
}
|
|
6534
|
+
});
|
|
6535
|
+
async function readNodesYaml(path) {
|
|
6536
|
+
let raw;
|
|
6537
|
+
try {
|
|
6538
|
+
raw = await fs.readFile(path, "utf-8");
|
|
6539
|
+
} catch (err) {
|
|
6540
|
+
if (err.code === "ENOENT") {
|
|
6541
|
+
return { entries: [] };
|
|
6542
|
+
}
|
|
6543
|
+
throw err;
|
|
6544
|
+
}
|
|
6545
|
+
const parsed = yamlParse(raw);
|
|
6546
|
+
if (parsed === null || parsed === void 0) {
|
|
6547
|
+
return { entries: [] };
|
|
6548
|
+
}
|
|
6549
|
+
return NodesYamlSchema.parse(parsed);
|
|
6550
|
+
}
|
|
6551
|
+
async function writeNodesYaml(path, data) {
|
|
6552
|
+
const validated = NodesYamlSchema.parse(data);
|
|
6553
|
+
const yamlContent = yamlStringify3(validated);
|
|
6554
|
+
const tmpPath = `${path}.tmp`;
|
|
6555
|
+
await fs.mkdir(dirname3(path), { recursive: true, mode: 448 });
|
|
6556
|
+
await fs.writeFile(tmpPath, yamlContent, { encoding: "utf-8", mode: 384 });
|
|
6557
|
+
await fs.rename(tmpPath, path);
|
|
6558
|
+
await fs.chmod(path, 384);
|
|
6559
|
+
}
|
|
6560
|
+
|
|
6561
|
+
// src/reconciler.ts
|
|
6562
|
+
import { promises as fs2 } from "fs";
|
|
6563
|
+
import { dirname as dirname4 } from "path";
|
|
6564
|
+
var BootReconciler = class {
|
|
6565
|
+
constructor(adminClient, nodesYamlPath, reconcilerLogPath) {
|
|
6566
|
+
this.adminClient = adminClient;
|
|
6567
|
+
this.nodesYamlPath = nodesYamlPath;
|
|
6568
|
+
this.reconcilerLogPath = reconcilerLogPath;
|
|
6569
|
+
}
|
|
6570
|
+
logDirEnsured = false;
|
|
6571
|
+
logFileChmodEnsured = false;
|
|
6572
|
+
/**
|
|
6573
|
+
* Diff `nodes.yaml` (truth) against `GET /admin/peers` (derived state)
|
|
6574
|
+
* and converge.
|
|
6575
|
+
*
|
|
6576
|
+
* Ordering rule (Epic 46.2 dependency — load-bearing):
|
|
6577
|
+
* `nodes.yaml` write happens BEFORE connector registration
|
|
6578
|
+
* (`POST /admin/peers`).
|
|
6579
|
+
*
|
|
6580
|
+
* The drift window resolves in the safe direction:
|
|
6581
|
+
* - yaml entry without a connector peer = harmless. The reconciler
|
|
6582
|
+
* re-registers it on next `hs up` (this method does that).
|
|
6583
|
+
* - connector peer without a yaml entry = treated as `'external'` and
|
|
6584
|
+
* left alone (operators may legitimately route non-Townhouse peers
|
|
6585
|
+
* through the same connector).
|
|
6586
|
+
*
|
|
6587
|
+
* The unsafe direction (register first, then write yaml) creates a
|
|
6588
|
+
* window where the connector routes to a peer Townhouse cannot clean
|
|
6589
|
+
* up. Epic 46.2's provisioning pipeline MUST honor the yaml-first rule.
|
|
6590
|
+
*
|
|
6591
|
+
* Failures fetching `getPeers()` are surfaced (not swallowed) so the
|
|
6592
|
+
* caller in `handleHsUp` can decide whether to treat reconciler
|
|
6593
|
+
* divergence as fatal. (Today: non-fatal — see cli.ts wire point.)
|
|
6594
|
+
*
|
|
6595
|
+
* Per-divergence appendLog failures are caught so a single log-write
|
|
6596
|
+
* failure does not abort the rest of the reconciliation pass.
|
|
6597
|
+
*/
|
|
6598
|
+
async reconcile() {
|
|
6599
|
+
const yaml = await readNodesYaml(this.nodesYamlPath);
|
|
6600
|
+
const peers = await this.adminClient.getPeers();
|
|
6601
|
+
const plans = this.diff(yaml, peers);
|
|
6602
|
+
const summary = {
|
|
6603
|
+
reregistered: 0,
|
|
6604
|
+
failed: 0,
|
|
6605
|
+
external: 0
|
|
6606
|
+
};
|
|
6607
|
+
for (const plan of plans) {
|
|
6608
|
+
if (plan.intent === "reregister") {
|
|
6609
|
+
const entry = yaml.entries.find((e) => e.peerId === plan.peerId);
|
|
6610
|
+
if (!entry) continue;
|
|
6611
|
+
try {
|
|
6612
|
+
await this.adminClient.registerPeer({
|
|
6613
|
+
id: entry.peerId,
|
|
6614
|
+
url: deriveBtpUrl(entry),
|
|
6615
|
+
authToken: "",
|
|
6616
|
+
routes: [{ prefix: entry.ilpAddress, priority: 0 }]
|
|
6617
|
+
});
|
|
6618
|
+
summary.reregistered++;
|
|
6619
|
+
await this.tryAppendLog({
|
|
6620
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
6621
|
+
peerId: plan.peerId,
|
|
6622
|
+
action: "reregistered"
|
|
6623
|
+
});
|
|
6624
|
+
} catch (err) {
|
|
6625
|
+
summary.failed++;
|
|
6626
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
6627
|
+
await this.tryAppendLog({
|
|
6628
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
6629
|
+
peerId: plan.peerId,
|
|
6630
|
+
action: "reregister-failed",
|
|
6631
|
+
detail: msg
|
|
6632
|
+
});
|
|
6633
|
+
}
|
|
6634
|
+
} else {
|
|
6635
|
+
summary.external++;
|
|
6636
|
+
await this.tryAppendLog({
|
|
6637
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
6638
|
+
peerId: plan.peerId,
|
|
6639
|
+
action: "external"
|
|
6640
|
+
});
|
|
6641
|
+
}
|
|
6642
|
+
}
|
|
6643
|
+
return summary;
|
|
6644
|
+
}
|
|
6645
|
+
/**
|
|
6646
|
+
* Compute divergences without mutating the connector. Exposed for
|
|
6647
|
+
* testability — production callers use `reconcile()`.
|
|
6648
|
+
*/
|
|
6649
|
+
diff(yaml, peers) {
|
|
6650
|
+
const peerIds = new Set(peers.map((p) => p.id));
|
|
6651
|
+
const yamlPeerIds = new Set(yaml.entries.map((e) => e.peerId));
|
|
6652
|
+
const out = [];
|
|
6653
|
+
for (const entry of yaml.entries) {
|
|
6654
|
+
if (!peerIds.has(entry.peerId)) {
|
|
6655
|
+
out.push({ peerId: entry.peerId, intent: "reregister" });
|
|
6656
|
+
}
|
|
6657
|
+
}
|
|
6658
|
+
for (const peer of peers) {
|
|
6659
|
+
if (!yamlPeerIds.has(peer.id)) {
|
|
6660
|
+
out.push({ peerId: peer.id, intent: "external" });
|
|
6661
|
+
}
|
|
6662
|
+
}
|
|
6663
|
+
return out;
|
|
6664
|
+
}
|
|
6665
|
+
/**
|
|
6666
|
+
* Append one divergence record without aborting the whole reconciliation
|
|
6667
|
+
* pass on a single log-write failure (disk full, EACCES, etc.). Failures
|
|
6668
|
+
* are themselves logged to stderr — not silently swallowed — so the
|
|
6669
|
+
* operator can see them in the same `hs up` session.
|
|
6670
|
+
*/
|
|
6671
|
+
async tryAppendLog(div) {
|
|
6672
|
+
try {
|
|
6673
|
+
await this.appendLog(div);
|
|
6674
|
+
} catch (err) {
|
|
6675
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
6676
|
+
console.error(
|
|
6677
|
+
`[townhouse boot-reconciler] failed to append divergence log: ${msg}`
|
|
6678
|
+
);
|
|
6679
|
+
}
|
|
6680
|
+
}
|
|
6681
|
+
/**
|
|
6682
|
+
* Append one divergence record to the reconciler log as a single line of
|
|
6683
|
+
* JSON (jsonl-style — easy to grep, easy to parse).
|
|
6684
|
+
*
|
|
6685
|
+
* `mkdir` runs once per reconciler instance. `chmod 0o600` on the log file
|
|
6686
|
+
* also runs once — `fs.appendFile`'s `mode` option only applies on
|
|
6687
|
+
* creation, so without a post-create chmod a pre-existing log file with
|
|
6688
|
+
* permissive mode would never be tightened.
|
|
6689
|
+
*/
|
|
6690
|
+
async appendLog(div) {
|
|
6691
|
+
const line = JSON.stringify(div) + "\n";
|
|
6692
|
+
if (!this.logDirEnsured) {
|
|
6693
|
+
await fs2.mkdir(dirname4(this.reconcilerLogPath), {
|
|
6694
|
+
recursive: true,
|
|
6695
|
+
mode: 448
|
|
6696
|
+
});
|
|
6697
|
+
this.logDirEnsured = true;
|
|
6698
|
+
}
|
|
6699
|
+
await fs2.appendFile(this.reconcilerLogPath, line, {
|
|
6700
|
+
encoding: "utf-8",
|
|
6701
|
+
mode: 384
|
|
6702
|
+
});
|
|
6703
|
+
if (!this.logFileChmodEnsured) {
|
|
6704
|
+
try {
|
|
6705
|
+
await fs2.chmod(this.reconcilerLogPath, 384);
|
|
6706
|
+
} catch {
|
|
6707
|
+
}
|
|
6708
|
+
this.logFileChmodEnsured = true;
|
|
6709
|
+
}
|
|
6710
|
+
}
|
|
6711
|
+
};
|
|
6712
|
+
function deriveBtpUrl(entry) {
|
|
6713
|
+
return `ws://${CONTAINER_PREFIX}${entry.type}:${NODE_BTP_PORT}`;
|
|
6714
|
+
}
|
|
6715
|
+
|
|
6716
|
+
// src/earnings/snapshot-writer.ts
|
|
6717
|
+
import { promises as fs3 } from "fs";
|
|
6718
|
+
import { dirname as dirname5 } from "path";
|
|
6719
|
+
var SnapshotWriter = class {
|
|
6720
|
+
constructor(opts) {
|
|
6721
|
+
this.opts = opts;
|
|
6722
|
+
}
|
|
6723
|
+
timer = null;
|
|
6724
|
+
tickPending = false;
|
|
6725
|
+
start() {
|
|
6726
|
+
if (this.timer !== null) return;
|
|
6727
|
+
const intervalMs = this.opts.tickIntervalMs ?? 36e5;
|
|
6728
|
+
this.timer = setInterval(() => {
|
|
6729
|
+
void this.tick();
|
|
6730
|
+
}, intervalMs);
|
|
6731
|
+
if (this.opts.fireOnStart) {
|
|
6732
|
+
void this.tick();
|
|
6733
|
+
}
|
|
6734
|
+
}
|
|
6735
|
+
stop() {
|
|
6736
|
+
if (this.timer !== null) {
|
|
6737
|
+
clearInterval(this.timer);
|
|
6738
|
+
this.timer = null;
|
|
6739
|
+
}
|
|
6740
|
+
}
|
|
6741
|
+
/** Exposed for test ergonomics — runs one full append+prune cycle. */
|
|
6742
|
+
async tick() {
|
|
6743
|
+
if (this.tickPending) {
|
|
6744
|
+
this.opts.logger?.warn(
|
|
6745
|
+
{ snapshotPath: this.opts.snapshotPath },
|
|
6746
|
+
"snapshot writer: tick skipped \u2014 previous tick still in flight"
|
|
6747
|
+
);
|
|
6748
|
+
return;
|
|
6749
|
+
}
|
|
6750
|
+
this.tickPending = true;
|
|
6751
|
+
try {
|
|
6752
|
+
await this.runTick();
|
|
6753
|
+
} finally {
|
|
6754
|
+
this.tickPending = false;
|
|
6755
|
+
}
|
|
6756
|
+
}
|
|
6757
|
+
async runTick() {
|
|
6758
|
+
const now = (this.opts.now ?? (() => /* @__PURE__ */ new Date()))();
|
|
6759
|
+
const tsMs = Math.floor(now.getTime() / 36e5) * 36e5;
|
|
6760
|
+
const ts = new Date(tsMs).toISOString();
|
|
6761
|
+
let earnings;
|
|
6762
|
+
try {
|
|
6763
|
+
earnings = await this.opts.connectorAdmin.getEarnings();
|
|
6764
|
+
} catch (err) {
|
|
6765
|
+
this.opts.logger?.warn(
|
|
6766
|
+
{ err },
|
|
6767
|
+
"snapshot writer: getEarnings failed \u2014 skipping this tick"
|
|
6768
|
+
);
|
|
6769
|
+
return;
|
|
6770
|
+
}
|
|
6771
|
+
try {
|
|
6772
|
+
const entries = [];
|
|
6773
|
+
for (const peer of earnings.peers ?? []) {
|
|
6774
|
+
if (peer.peerId === "__apex__") {
|
|
6775
|
+
this.opts.logger?.warn(
|
|
6776
|
+
{ peerId: peer.peerId },
|
|
6777
|
+
'snapshot writer: peer with reserved id "__apex__" \u2014 row dropped'
|
|
6778
|
+
);
|
|
6779
|
+
continue;
|
|
6780
|
+
}
|
|
6781
|
+
for (const a of peer.byAsset ?? []) {
|
|
6782
|
+
entries.push({
|
|
6783
|
+
ts,
|
|
6784
|
+
peerId: peer.peerId,
|
|
6785
|
+
assetCode: a.assetCode,
|
|
6786
|
+
claimsReceivedTotal: a.claimsReceivedTotal
|
|
6787
|
+
});
|
|
6788
|
+
}
|
|
6789
|
+
}
|
|
6790
|
+
for (const fee of earnings.connectorFees ?? []) {
|
|
6791
|
+
entries.push({
|
|
6792
|
+
ts,
|
|
6793
|
+
peerId: "__apex__",
|
|
6794
|
+
assetCode: fee.assetCode,
|
|
6795
|
+
claimsReceivedTotal: fee.total
|
|
5408
6796
|
});
|
|
5409
|
-
req.end();
|
|
5410
|
-
} catch (err) {
|
|
5411
|
-
clearTimeout(timeout);
|
|
5412
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
5413
|
-
console.debug(`[TransportProbe] direct latency probe threw: ${msg}`);
|
|
5414
|
-
settle(null);
|
|
5415
6797
|
}
|
|
6798
|
+
if (entries.length === 0) {
|
|
6799
|
+
await this.pruneIfNeeded(now);
|
|
6800
|
+
return;
|
|
6801
|
+
}
|
|
6802
|
+
await this.appendEntries(entries);
|
|
6803
|
+
await this.pruneIfNeeded(now);
|
|
6804
|
+
} catch (err) {
|
|
6805
|
+
this.opts.logger?.warn(
|
|
6806
|
+
{ err },
|
|
6807
|
+
"snapshot writer: append/prune failed \u2014 skipping this tick"
|
|
6808
|
+
);
|
|
6809
|
+
}
|
|
6810
|
+
}
|
|
6811
|
+
async appendEntries(entries) {
|
|
6812
|
+
await fs3.mkdir(dirname5(this.opts.snapshotPath), {
|
|
6813
|
+
recursive: true,
|
|
6814
|
+
mode: 448
|
|
6815
|
+
});
|
|
6816
|
+
const body = entries.map((e) => JSON.stringify(e)).join("\n") + "\n";
|
|
6817
|
+
await fs3.appendFile(this.opts.snapshotPath, body, {
|
|
6818
|
+
encoding: "utf-8",
|
|
6819
|
+
mode: 384
|
|
5416
6820
|
});
|
|
6821
|
+
await fs3.chmod(this.opts.snapshotPath, 384);
|
|
5417
6822
|
}
|
|
5418
|
-
|
|
5419
|
-
|
|
5420
|
-
|
|
5421
|
-
|
|
5422
|
-
|
|
5423
|
-
|
|
5424
|
-
|
|
5425
|
-
}
|
|
5426
|
-
|
|
6823
|
+
async pruneIfNeeded(now) {
|
|
6824
|
+
const WATERMARK = 256 * 1024;
|
|
6825
|
+
let stat3 = null;
|
|
6826
|
+
try {
|
|
6827
|
+
stat3 = await fs3.stat(this.opts.snapshotPath);
|
|
6828
|
+
} catch {
|
|
6829
|
+
return;
|
|
6830
|
+
}
|
|
6831
|
+
if (stat3.size < WATERMARK) return;
|
|
6832
|
+
const retentionMonths = this.opts.retentionMonths ?? 13;
|
|
6833
|
+
const cutoff = new Date(now);
|
|
6834
|
+
cutoff.setUTCMonth(cutoff.getUTCMonth() - retentionMonths);
|
|
6835
|
+
const cutoffMs = cutoff.getTime();
|
|
6836
|
+
let raw;
|
|
6837
|
+
try {
|
|
6838
|
+
raw = await fs3.readFile(this.opts.snapshotPath, "utf-8");
|
|
6839
|
+
} catch {
|
|
6840
|
+
return;
|
|
6841
|
+
}
|
|
6842
|
+
const lines = raw.split("\n").filter((l) => l.length > 0);
|
|
6843
|
+
let anyDropped = false;
|
|
6844
|
+
const kept = [];
|
|
6845
|
+
for (const line of lines) {
|
|
6846
|
+
let entry;
|
|
6847
|
+
try {
|
|
6848
|
+
entry = JSON.parse(line);
|
|
6849
|
+
} catch {
|
|
6850
|
+
anyDropped = true;
|
|
6851
|
+
continue;
|
|
6852
|
+
}
|
|
6853
|
+
if (!isSnapshotEntry(entry)) {
|
|
6854
|
+
anyDropped = true;
|
|
6855
|
+
continue;
|
|
6856
|
+
}
|
|
6857
|
+
const entryMs = new Date(entry.ts).getTime();
|
|
6858
|
+
if (isNaN(entryMs) || entryMs < cutoffMs) {
|
|
6859
|
+
anyDropped = true;
|
|
6860
|
+
} else {
|
|
6861
|
+
kept.push(line);
|
|
6862
|
+
}
|
|
5427
6863
|
}
|
|
6864
|
+
if (!anyDropped) return;
|
|
6865
|
+
const tmpPath = `${this.opts.snapshotPath}.tmp`;
|
|
6866
|
+
const newContent = kept.length > 0 ? kept.join("\n") + "\n" : "";
|
|
6867
|
+
await fs3.writeFile(tmpPath, newContent, { encoding: "utf-8", mode: 384 });
|
|
6868
|
+
await fs3.rename(tmpPath, this.opts.snapshotPath);
|
|
6869
|
+
await fs3.chmod(this.opts.snapshotPath, 384);
|
|
5428
6870
|
}
|
|
5429
6871
|
};
|
|
6872
|
+
function isSnapshotEntry(v) {
|
|
6873
|
+
if (typeof v !== "object" || v === null) return false;
|
|
6874
|
+
const e = v;
|
|
6875
|
+
return typeof e["ts"] === "string" && typeof e["peerId"] === "string" && typeof e["assetCode"] === "string" && typeof e["claimsReceivedTotal"] === "string";
|
|
6876
|
+
}
|
|
5430
6877
|
|
|
5431
6878
|
// src/wallet/storage.ts
|
|
5432
6879
|
import { writeFile, readFile, mkdir, stat } from "fs/promises";
|
|
5433
|
-
import { dirname as
|
|
6880
|
+
import { dirname as dirname6 } from "path";
|
|
5434
6881
|
async function saveWallet(path, encrypted) {
|
|
5435
|
-
const dir =
|
|
6882
|
+
const dir = dirname6(path);
|
|
5436
6883
|
await mkdir(dir, { recursive: true, mode: 448 });
|
|
5437
6884
|
const data = JSON.stringify(encrypted, null, 2);
|
|
5438
6885
|
await writeFile(path, data, { encoding: "utf-8", mode: 384 });
|
|
@@ -5534,6 +6981,115 @@ function decryptWallet(encrypted, password) {
|
|
|
5534
6981
|
key.fill(0);
|
|
5535
6982
|
}
|
|
5536
6983
|
}
|
|
6984
|
+
function encryptString(plaintext, password) {
|
|
6985
|
+
const salt = randomBytes(SALT_LEN);
|
|
6986
|
+
const iv = randomBytes(IV_LEN);
|
|
6987
|
+
const key = scryptSync(password, salt, SCRYPT_KEY_LEN, {
|
|
6988
|
+
N: SCRYPT_N,
|
|
6989
|
+
r: SCRYPT_R,
|
|
6990
|
+
p: SCRYPT_P,
|
|
6991
|
+
maxmem: SCRYPT_MAXMEM
|
|
6992
|
+
});
|
|
6993
|
+
try {
|
|
6994
|
+
const cipher = createCipheriv("aes-256-gcm", key, iv, {
|
|
6995
|
+
authTagLength: AUTH_TAG_LEN
|
|
6996
|
+
});
|
|
6997
|
+
const ciphertext = Buffer.concat([
|
|
6998
|
+
cipher.update(plaintext, "utf8"),
|
|
6999
|
+
cipher.final()
|
|
7000
|
+
]);
|
|
7001
|
+
const tag = cipher.getAuthTag();
|
|
7002
|
+
return {
|
|
7003
|
+
salt: salt.toString("base64"),
|
|
7004
|
+
iv: iv.toString("base64"),
|
|
7005
|
+
ciphertext: ciphertext.toString("base64"),
|
|
7006
|
+
tag: tag.toString("base64")
|
|
7007
|
+
};
|
|
7008
|
+
} finally {
|
|
7009
|
+
key.fill(0);
|
|
7010
|
+
}
|
|
7011
|
+
}
|
|
7012
|
+
function decryptString(encrypted, password) {
|
|
7013
|
+
const salt = Buffer.from(encrypted.salt, "base64");
|
|
7014
|
+
const iv = Buffer.from(encrypted.iv, "base64");
|
|
7015
|
+
const ciphertext = Buffer.from(encrypted.ciphertext, "base64");
|
|
7016
|
+
const tag = Buffer.from(encrypted.tag, "base64");
|
|
7017
|
+
const key = scryptSync(password, salt, SCRYPT_KEY_LEN, {
|
|
7018
|
+
N: SCRYPT_N,
|
|
7019
|
+
r: SCRYPT_R,
|
|
7020
|
+
p: SCRYPT_P,
|
|
7021
|
+
maxmem: SCRYPT_MAXMEM
|
|
7022
|
+
});
|
|
7023
|
+
try {
|
|
7024
|
+
const decipher = createDecipheriv("aes-256-gcm", key, iv, {
|
|
7025
|
+
authTagLength: AUTH_TAG_LEN
|
|
7026
|
+
});
|
|
7027
|
+
decipher.setAuthTag(tag);
|
|
7028
|
+
try {
|
|
7029
|
+
const plaintext = Buffer.concat([
|
|
7030
|
+
decipher.update(ciphertext),
|
|
7031
|
+
decipher.final()
|
|
7032
|
+
]);
|
|
7033
|
+
return plaintext.toString("utf8");
|
|
7034
|
+
} catch {
|
|
7035
|
+
throw new Error(
|
|
7036
|
+
"Decryption failed: wrong password or corrupted ciphertext"
|
|
7037
|
+
);
|
|
7038
|
+
}
|
|
7039
|
+
} finally {
|
|
7040
|
+
key.fill(0);
|
|
7041
|
+
}
|
|
7042
|
+
}
|
|
7043
|
+
function encryptArweaveJwk(jwk, password) {
|
|
7044
|
+
return encryptString(JSON.stringify(jwk), password);
|
|
7045
|
+
}
|
|
7046
|
+
function decryptArweaveJwk(encrypted, password) {
|
|
7047
|
+
const plaintext = decryptString(encrypted, password);
|
|
7048
|
+
let parsed;
|
|
7049
|
+
try {
|
|
7050
|
+
parsed = JSON.parse(plaintext);
|
|
7051
|
+
} catch {
|
|
7052
|
+
throw new Error(
|
|
7053
|
+
"Arweave JWK cache is corrupt: plaintext is not valid JSON"
|
|
7054
|
+
);
|
|
7055
|
+
}
|
|
7056
|
+
if (typeof parsed !== "object" || parsed === null || parsed.kty !== "RSA" || typeof parsed.n !== "string" || typeof parsed.e !== "string") {
|
|
7057
|
+
throw new Error(
|
|
7058
|
+
"Arweave JWK cache is corrupt: plaintext is not a well-formed RSA JWK"
|
|
7059
|
+
);
|
|
7060
|
+
}
|
|
7061
|
+
return parsed;
|
|
7062
|
+
}
|
|
7063
|
+
|
|
7064
|
+
// src/state/image-manifest.ts
|
|
7065
|
+
import { promises as fs4 } from "fs";
|
|
7066
|
+
import { z as z2 } from "zod";
|
|
7067
|
+
var ImageEntrySchema = z2.object({
|
|
7068
|
+
name: z2.string().min(1),
|
|
7069
|
+
tag: z2.string().min(1),
|
|
7070
|
+
digest: z2.string().regex(/^sha256:[0-9a-f]{64}$/)
|
|
7071
|
+
}).strict();
|
|
7072
|
+
var ImageManifestSchema = z2.object({
|
|
7073
|
+
schemaVersion: z2.literal(1),
|
|
7074
|
+
townhouseVersion: z2.string(),
|
|
7075
|
+
builtAt: z2.string().datetime({ offset: true }),
|
|
7076
|
+
images: z2.object({
|
|
7077
|
+
"townhouse-api": ImageEntrySchema,
|
|
7078
|
+
town: ImageEntrySchema,
|
|
7079
|
+
mill: ImageEntrySchema,
|
|
7080
|
+
dvm: ImageEntrySchema,
|
|
7081
|
+
connector: ImageEntrySchema
|
|
7082
|
+
}).strict()
|
|
7083
|
+
}).strict();
|
|
7084
|
+
var SYNTHETIC_DIGEST_SENTINEL = "sha256:dead000000000000000000000000000000000000000000000000000000000000";
|
|
7085
|
+
function isSyntheticDigest(digest) {
|
|
7086
|
+
return digest === SYNTHETIC_DIGEST_SENTINEL;
|
|
7087
|
+
}
|
|
7088
|
+
async function readImageManifest(path) {
|
|
7089
|
+
const raw = await fs4.readFile(path, "utf-8");
|
|
7090
|
+
const parsed = JSON.parse(raw);
|
|
7091
|
+
return ImageManifestSchema.parse(parsed);
|
|
7092
|
+
}
|
|
5537
7093
|
|
|
5538
7094
|
// src/wallet/manager.ts
|
|
5539
7095
|
import {
|
|
@@ -5547,6 +7103,112 @@ import { getPublicKey } from "nostr-tools/pure";
|
|
|
5547
7103
|
import { bytesToHex } from "@noble/hashes/utils";
|
|
5548
7104
|
import { keccak_256 } from "@noble/hashes/sha3";
|
|
5549
7105
|
import { secp256k1 } from "@noble/curves/secp256k1";
|
|
7106
|
+
import { createPrivateKey, createHash } from "crypto";
|
|
7107
|
+
|
|
7108
|
+
// src/wallet/ar-cache.ts
|
|
7109
|
+
import { mkdir as mkdir2, readFile as readFile2, stat as stat2, writeFile as writeFile2, unlink } from "fs/promises";
|
|
7110
|
+
import { dirname as dirname7 } from "path";
|
|
7111
|
+
function arweaveCachePath(encryptedWalletPath) {
|
|
7112
|
+
return `${dirname7(encryptedWalletPath)}/wallet.arweave.enc`;
|
|
7113
|
+
}
|
|
7114
|
+
async function loadArweaveCacheFile(path) {
|
|
7115
|
+
let data;
|
|
7116
|
+
try {
|
|
7117
|
+
data = await readFile2(path, "utf-8");
|
|
7118
|
+
} catch (err) {
|
|
7119
|
+
if (err instanceof Error && "code" in err && err.code === "ENOENT") {
|
|
7120
|
+
return null;
|
|
7121
|
+
}
|
|
7122
|
+
throw err;
|
|
7123
|
+
}
|
|
7124
|
+
try {
|
|
7125
|
+
const stats = await stat2(path);
|
|
7126
|
+
const mode = stats.mode & 511;
|
|
7127
|
+
if (mode & 63) {
|
|
7128
|
+
console.error(
|
|
7129
|
+
`Warning: arweave cache ${path} has permissions ${mode.toString(8)} (should be 600)`
|
|
7130
|
+
);
|
|
7131
|
+
}
|
|
7132
|
+
} catch {
|
|
7133
|
+
}
|
|
7134
|
+
let parsed;
|
|
7135
|
+
try {
|
|
7136
|
+
parsed = JSON.parse(data);
|
|
7137
|
+
} catch {
|
|
7138
|
+
throw new Error(
|
|
7139
|
+
`Arweave JWK cache at ${path} is corrupt: not valid JSON. Delete the file and re-run to re-derive.`
|
|
7140
|
+
);
|
|
7141
|
+
}
|
|
7142
|
+
if (typeof parsed !== "object" || parsed === null || parsed.version !== 1 || typeof parsed.nodes !== "object" || parsed.nodes === null) {
|
|
7143
|
+
throw new Error(
|
|
7144
|
+
`Arweave JWK cache at ${path} is corrupt: unexpected envelope shape. Delete the file and re-run to re-derive.`
|
|
7145
|
+
);
|
|
7146
|
+
}
|
|
7147
|
+
return parsed;
|
|
7148
|
+
}
|
|
7149
|
+
async function readArweaveJwkFromCache(path, nodeType, password, expectedFingerprint) {
|
|
7150
|
+
const file = await loadArweaveCacheFile(path);
|
|
7151
|
+
if (!file) return { status: "miss" };
|
|
7152
|
+
const entry = file.nodes[nodeType];
|
|
7153
|
+
if (!entry) return { status: "miss" };
|
|
7154
|
+
if (typeof entry.subSeedFingerprint !== "string" || typeof entry.arweaveAddress !== "string" || typeof entry.encryptedJwk !== "object" || entry.encryptedJwk === null) {
|
|
7155
|
+
throw new Error(
|
|
7156
|
+
`Arweave JWK cache entry for ${nodeType} at ${path} is corrupt: missing fields.`
|
|
7157
|
+
);
|
|
7158
|
+
}
|
|
7159
|
+
if (entry.subSeedFingerprint !== expectedFingerprint) {
|
|
7160
|
+
return {
|
|
7161
|
+
status: "stale",
|
|
7162
|
+
cachedFingerprint: entry.subSeedFingerprint,
|
|
7163
|
+
cachedAddress: entry.arweaveAddress
|
|
7164
|
+
};
|
|
7165
|
+
}
|
|
7166
|
+
const jwk = decryptArweaveJwk(entry.encryptedJwk, password);
|
|
7167
|
+
return { status: "hit", jwk, entry };
|
|
7168
|
+
}
|
|
7169
|
+
async function writeArweaveJwkToCache(path, nodeType, jwk, password, subSeedFingerprint, arweaveAddress) {
|
|
7170
|
+
const existing = await loadArweaveCacheFile(path);
|
|
7171
|
+
const entry = {
|
|
7172
|
+
subSeedFingerprint,
|
|
7173
|
+
arweaveAddress,
|
|
7174
|
+
encryptedJwk: encryptArweaveJwk(jwk, password)
|
|
7175
|
+
};
|
|
7176
|
+
const file = existing ?? {
|
|
7177
|
+
version: 1,
|
|
7178
|
+
nodes: {}
|
|
7179
|
+
};
|
|
7180
|
+
file.nodes[nodeType] = entry;
|
|
7181
|
+
const dir = dirname7(path);
|
|
7182
|
+
await mkdir2(dir, { recursive: true, mode: 448 });
|
|
7183
|
+
await writeFile2(path, JSON.stringify(file, null, 2), {
|
|
7184
|
+
encoding: "utf-8",
|
|
7185
|
+
mode: 384
|
|
7186
|
+
});
|
|
7187
|
+
}
|
|
7188
|
+
async function deleteArweaveJwkFromCache(path, nodeType) {
|
|
7189
|
+
const file = await loadArweaveCacheFile(path);
|
|
7190
|
+
if (!file) return;
|
|
7191
|
+
if (!(nodeType in file.nodes)) return;
|
|
7192
|
+
const { [nodeType]: _removed, ...remaining } = file.nodes;
|
|
7193
|
+
file.nodes = remaining;
|
|
7194
|
+
if (Object.keys(file.nodes).length === 0) {
|
|
7195
|
+
try {
|
|
7196
|
+
await unlink(path);
|
|
7197
|
+
} catch (err) {
|
|
7198
|
+
if (err instanceof Error && "code" in err && err.code === "ENOENT") {
|
|
7199
|
+
return;
|
|
7200
|
+
}
|
|
7201
|
+
throw err;
|
|
7202
|
+
}
|
|
7203
|
+
return;
|
|
7204
|
+
}
|
|
7205
|
+
await writeFile2(path, JSON.stringify(file, null, 2), {
|
|
7206
|
+
encoding: "utf-8",
|
|
7207
|
+
mode: 384
|
|
7208
|
+
});
|
|
7209
|
+
}
|
|
7210
|
+
|
|
7211
|
+
// src/wallet/manager.ts
|
|
5550
7212
|
import { deriveMillKeys } from "@toon-protocol/mill";
|
|
5551
7213
|
var BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
|
|
5552
7214
|
function base58Encode(bytes) {
|
|
@@ -5633,10 +7295,15 @@ var WalletManager = class {
|
|
|
5633
7295
|
nostrDerivationPath: keys.nostrDerivationPath,
|
|
5634
7296
|
evmDerivationPath: keys.evmDerivationPath
|
|
5635
7297
|
};
|
|
5636
|
-
if (
|
|
5637
|
-
|
|
5638
|
-
|
|
5639
|
-
|
|
7298
|
+
if (keys.solanaAddress) info.solanaAddress = keys.solanaAddress;
|
|
7299
|
+
if (keys.solanaDerivationPath)
|
|
7300
|
+
info.solanaDerivationPath = keys.solanaDerivationPath;
|
|
7301
|
+
if (nodeType === "mill" && keys.minaAddress) {
|
|
7302
|
+
info.minaAddress = keys.minaAddress;
|
|
7303
|
+
}
|
|
7304
|
+
if (keys.arweaveAddress) info.arweaveAddress = keys.arweaveAddress;
|
|
7305
|
+
if (keys.arweaveDerivationPath)
|
|
7306
|
+
info.arweaveDerivationPath = keys.arweaveDerivationPath;
|
|
5640
7307
|
return info;
|
|
5641
7308
|
});
|
|
5642
7309
|
}
|
|
@@ -5657,56 +7324,273 @@ var WalletManager = class {
|
|
|
5657
7324
|
const keys = this.state.keys[nodeType];
|
|
5658
7325
|
keys.nostrSecretKey.fill(0);
|
|
5659
7326
|
keys.evmPrivateKey.fill(0);
|
|
7327
|
+
if (keys.solanaPrivateKey) keys.solanaPrivateKey.fill(0);
|
|
7328
|
+
if (keys.arweaveJwk) zeroArweaveJwk(keys.arweaveJwk);
|
|
5660
7329
|
}
|
|
5661
7330
|
this.state = null;
|
|
5662
7331
|
}
|
|
5663
7332
|
/**
|
|
5664
|
-
*
|
|
7333
|
+
* Return the BIP-39 mnemonic from in-memory wallet state.
|
|
7334
|
+
* Returns null when the wallet is locked or not initialized.
|
|
5665
7335
|
*/
|
|
5666
|
-
|
|
7336
|
+
getMnemonic() {
|
|
7337
|
+
return this.state?.mnemonic ?? null;
|
|
7338
|
+
}
|
|
7339
|
+
/**
|
|
7340
|
+
* Get derived keys for a specific node type at a given derivation index.
|
|
7341
|
+
*
|
|
7342
|
+
* Pure derivation — does NOT mutate `state`. Re-derives from the stored
|
|
7343
|
+
* mnemonic each time it is called. For every node type, also derives the
|
|
7344
|
+
* Solana key at the same account index. For 'dvm', also derives Arweave.
|
|
7345
|
+
* Throws if the wallet is locked.
|
|
7346
|
+
*
|
|
7347
|
+
* v1 callers MUST pass `derivationIndex = ACCOUNT_INDEX_{type}` for the
|
|
7348
|
+
* first (and only) instance per type. Multi-instance support is out of
|
|
7349
|
+
* scope for v1 — the route layer enforces single-instance-per-type.
|
|
7350
|
+
*/
|
|
7351
|
+
async deriveNodeKey(type, derivationIndex) {
|
|
7352
|
+
if (!this.state) {
|
|
7353
|
+
throw new Error(
|
|
7354
|
+
"Wallet not initialized. Call generate() or fromMnemonic() first."
|
|
7355
|
+
);
|
|
7356
|
+
}
|
|
7357
|
+
const mnemonic = this.state.mnemonic;
|
|
5667
7358
|
let seed;
|
|
5668
7359
|
try {
|
|
5669
7360
|
seed = mnemonicToSeedSync(mnemonic);
|
|
5670
|
-
const
|
|
7361
|
+
const baseKeys = this.deriveNodeKeys(seed, type, derivationIndex);
|
|
7362
|
+
const chains = type === "mill" ? ["solana", "mina"] : ["solana"];
|
|
5671
7363
|
let solanaAddress;
|
|
7364
|
+
let solanaPrivateKey;
|
|
7365
|
+
let solanaDerivationPath;
|
|
5672
7366
|
let minaAddress;
|
|
5673
7367
|
try {
|
|
5674
|
-
const
|
|
7368
|
+
const chainKeys = await deriveMillKeys({
|
|
5675
7369
|
mnemonic,
|
|
5676
|
-
chains
|
|
5677
|
-
accountIndex:
|
|
7370
|
+
chains,
|
|
7371
|
+
accountIndex: derivationIndex
|
|
5678
7372
|
});
|
|
5679
|
-
if (
|
|
5680
|
-
solanaAddress = base58Encode(
|
|
7373
|
+
if (chainKeys.solana) {
|
|
7374
|
+
solanaAddress = base58Encode(chainKeys.solana.publicKey);
|
|
7375
|
+
solanaPrivateKey = chainKeys.solana.privateKey;
|
|
7376
|
+
solanaDerivationPath = chainKeys.solana.path;
|
|
7377
|
+
}
|
|
7378
|
+
if (chainKeys.mina && type === "mill") {
|
|
7379
|
+
minaAddress = chainKeys.mina.publicKey;
|
|
7380
|
+
}
|
|
7381
|
+
} catch (err) {
|
|
7382
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
7383
|
+
console.warn(
|
|
7384
|
+
`[WalletManager] deriveMillKeys failed for type=${type} accountIndex=${derivationIndex}: ${errMsg} \u2014 Solana/Mina addresses omitted`
|
|
7385
|
+
);
|
|
7386
|
+
}
|
|
7387
|
+
return {
|
|
7388
|
+
...baseKeys,
|
|
7389
|
+
solanaAddress,
|
|
7390
|
+
solanaPrivateKey,
|
|
7391
|
+
solanaDerivationPath,
|
|
7392
|
+
minaAddress
|
|
7393
|
+
};
|
|
7394
|
+
} finally {
|
|
7395
|
+
if (seed) seed.fill(0);
|
|
7396
|
+
}
|
|
7397
|
+
}
|
|
7398
|
+
// ── Private-key accessors (epic-49 credit funding) ───────────────────────
|
|
7399
|
+
/**
|
|
7400
|
+
* Returns the EVM private key for a node as a 64-char lowercase hex string.
|
|
7401
|
+
* Throws when the wallet is locked. Callers MUST treat the returned string
|
|
7402
|
+
* as sensitive (no logging, no persisting). The underlying Uint8Array is
|
|
7403
|
+
* still owned by WalletManager and will be zeroed by `lock()`.
|
|
7404
|
+
*/
|
|
7405
|
+
getEvmPrivateKeyHex(nodeType) {
|
|
7406
|
+
const keys = this.getNodeKeys(nodeType);
|
|
7407
|
+
return bytesToHex(keys.evmPrivateKey);
|
|
7408
|
+
}
|
|
7409
|
+
/**
|
|
7410
|
+
* Returns the Solana Ed25519 private key seed for a node as a 64-char
|
|
7411
|
+
* lowercase hex string (32 raw seed bytes). Throws when the wallet is
|
|
7412
|
+
* locked or when the Solana key was not derived for this node type.
|
|
7413
|
+
*/
|
|
7414
|
+
getSolanaPrivateKeyHex(nodeType) {
|
|
7415
|
+
const keys = this.getNodeKeys(nodeType);
|
|
7416
|
+
if (!keys.solanaPrivateKey) {
|
|
7417
|
+
throw new Error(
|
|
7418
|
+
`Solana private key not available for node '${nodeType}'`
|
|
7419
|
+
);
|
|
7420
|
+
}
|
|
7421
|
+
return bytesToHex(keys.solanaPrivateKey);
|
|
7422
|
+
}
|
|
7423
|
+
/**
|
|
7424
|
+
* Returns the Arweave RSA JWK for a node. Throws when the wallet is locked
|
|
7425
|
+
* or when AR derivation has not yet been triggered for this node type.
|
|
7426
|
+
*
|
|
7427
|
+
* Callers MUST `await ensureArweaveKey(nodeType)` first the first time per
|
|
7428
|
+
* unlock — RSA-4096 derivation is 5–30s and is therefore not done eagerly
|
|
7429
|
+
* at `fromMnemonic`/`generate` time. After the first `ensureArweaveKey`
|
|
7430
|
+
* the JWK is cached on the in-memory state until `lock()`.
|
|
7431
|
+
*/
|
|
7432
|
+
getArweaveJwk(nodeType) {
|
|
7433
|
+
const keys = this.getNodeKeys(nodeType);
|
|
7434
|
+
if (!keys.arweaveJwk) {
|
|
7435
|
+
throw new Error(
|
|
7436
|
+
`Arweave JWK not yet derived for node '${nodeType}'. Call ensureArweaveKey('${nodeType}') first (note: derivation takes 5\u201330s).`
|
|
7437
|
+
);
|
|
7438
|
+
}
|
|
7439
|
+
return keys.arweaveJwk;
|
|
7440
|
+
}
|
|
7441
|
+
/**
|
|
7442
|
+
* Lazily derive the Arweave RSA-4096 JWK for a node type and cache it on
|
|
7443
|
+
* the in-memory wallet state. Subsequent calls (within the same unlocked
|
|
7444
|
+
* session) return the cached result without re-deriving.
|
|
7445
|
+
*
|
|
7446
|
+
* Only meaningful for node types that participate in the Arweave credit
|
|
7447
|
+
* flow — `dvm` (account 2) in the current Townhouse layout. Calling on
|
|
7448
|
+
* `town` or `mill` will derive a valid AR key at the corresponding
|
|
7449
|
+
* account index but those keys are not used by any current code path.
|
|
7450
|
+
*
|
|
7451
|
+
* Throws if the wallet is locked or RSA derivation fails (unsupported
|
|
7452
|
+
* platform, etc.). On success the result is also reflected in subsequent
|
|
7453
|
+
* `getAllKeys()` calls (arweaveAddress + arweaveDerivationPath fields).
|
|
7454
|
+
*/
|
|
7455
|
+
async ensureArweaveKey(nodeType, password) {
|
|
7456
|
+
if (!this.state) {
|
|
7457
|
+
throw new Error(
|
|
7458
|
+
"Wallet not initialized. Call generate() or fromMnemonic() first."
|
|
7459
|
+
);
|
|
7460
|
+
}
|
|
7461
|
+
const existing = this.state.keys[nodeType].arweaveJwk;
|
|
7462
|
+
if (existing) return existing;
|
|
7463
|
+
const accountIndex = NODE_ACCOUNT_INDEX[nodeType];
|
|
7464
|
+
const path = `m/44'/472'/${accountIndex}'/0/0`;
|
|
7465
|
+
let seed;
|
|
7466
|
+
let subSeed;
|
|
7467
|
+
try {
|
|
7468
|
+
seed = mnemonicToSeedSync(this.state.mnemonic);
|
|
7469
|
+
const hdKey = HDKey.fromMasterSeed(seed).derive(path);
|
|
7470
|
+
if (!hdKey.privateKey) {
|
|
7471
|
+
throw new Error(`Arweave sub-seed missing at ${path}`);
|
|
7472
|
+
}
|
|
7473
|
+
subSeed = new Uint8Array(hdKey.privateKey);
|
|
7474
|
+
const fingerprint = createHash("sha256").update(subSeed).digest("base64url");
|
|
7475
|
+
if (password) {
|
|
7476
|
+
const cachePath = arweaveCachePath(this.config.encryptedPath);
|
|
7477
|
+
const result = await readArweaveJwkFromCache(
|
|
7478
|
+
cachePath,
|
|
7479
|
+
nodeType,
|
|
7480
|
+
password,
|
|
7481
|
+
fingerprint
|
|
7482
|
+
);
|
|
7483
|
+
if (result.status === "hit") {
|
|
7484
|
+
this.state.keys[nodeType].arweaveJwk = result.jwk;
|
|
7485
|
+
this.state.keys[nodeType].arweaveAddress = result.entry.arweaveAddress;
|
|
7486
|
+
this.state.keys[nodeType].arweaveDerivationPath = path;
|
|
7487
|
+
return result.jwk;
|
|
7488
|
+
}
|
|
7489
|
+
if (result.status === "stale") {
|
|
7490
|
+
console.warn(
|
|
7491
|
+
`[WalletManager] Arweave JWK cache for ${nodeType} was written from a different mnemonic (cached address ${result.cachedAddress.slice(0, 12)}\u2026). Discarding and re-deriving.`
|
|
7492
|
+
);
|
|
7493
|
+
await deleteArweaveJwkFromCache(cachePath, nodeType);
|
|
7494
|
+
}
|
|
7495
|
+
}
|
|
7496
|
+
const ar = await deriveArweaveKey(seed, accountIndex);
|
|
7497
|
+
if (!this.state) {
|
|
7498
|
+
zeroArweaveJwk(ar.jwk);
|
|
7499
|
+
throw new Error(
|
|
7500
|
+
"Wallet was locked during Arweave key derivation. Discarding derived key."
|
|
7501
|
+
);
|
|
7502
|
+
}
|
|
7503
|
+
this.state.keys[nodeType].arweaveJwk = ar.jwk;
|
|
7504
|
+
this.state.keys[nodeType].arweaveAddress = ar.address;
|
|
7505
|
+
this.state.keys[nodeType].arweaveDerivationPath = ar.path;
|
|
7506
|
+
if (password) {
|
|
7507
|
+
try {
|
|
7508
|
+
const cachePath = arweaveCachePath(this.config.encryptedPath);
|
|
7509
|
+
await writeArweaveJwkToCache(
|
|
7510
|
+
cachePath,
|
|
7511
|
+
nodeType,
|
|
7512
|
+
ar.jwk,
|
|
7513
|
+
password,
|
|
7514
|
+
fingerprint,
|
|
7515
|
+
ar.address
|
|
7516
|
+
);
|
|
7517
|
+
} catch (err) {
|
|
7518
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
7519
|
+
console.warn(
|
|
7520
|
+
`[WalletManager] Failed to write Arweave JWK cache (non-fatal): ${msg}`
|
|
7521
|
+
);
|
|
5681
7522
|
}
|
|
5682
|
-
|
|
5683
|
-
|
|
7523
|
+
}
|
|
7524
|
+
return ar.jwk;
|
|
7525
|
+
} finally {
|
|
7526
|
+
if (seed) seed.fill(0);
|
|
7527
|
+
if (subSeed) subSeed.fill(0);
|
|
7528
|
+
}
|
|
7529
|
+
}
|
|
7530
|
+
/**
|
|
7531
|
+
* Derive keys for all node types from a mnemonic.
|
|
7532
|
+
*/
|
|
7533
|
+
async deriveAllKeys(mnemonic) {
|
|
7534
|
+
let seed;
|
|
7535
|
+
try {
|
|
7536
|
+
seed = mnemonicToSeedSync(mnemonic);
|
|
7537
|
+
const chainExtras = {
|
|
7538
|
+
town: {},
|
|
7539
|
+
mill: {},
|
|
7540
|
+
dvm: {}
|
|
7541
|
+
};
|
|
7542
|
+
const types = ["town", "mill", "dvm"];
|
|
7543
|
+
for (const nodeType of types) {
|
|
7544
|
+
const accountIndex = NODE_ACCOUNT_INDEX[nodeType];
|
|
7545
|
+
const chains = nodeType === "mill" ? ["solana", "mina"] : ["solana"];
|
|
7546
|
+
try {
|
|
7547
|
+
const chainKeys = await deriveMillKeys({
|
|
7548
|
+
mnemonic,
|
|
7549
|
+
chains,
|
|
7550
|
+
accountIndex
|
|
7551
|
+
});
|
|
7552
|
+
if (chainKeys.solana) {
|
|
7553
|
+
chainExtras[nodeType].solanaAddress = base58Encode(
|
|
7554
|
+
chainKeys.solana.publicKey
|
|
7555
|
+
);
|
|
7556
|
+
chainExtras[nodeType].solanaPrivateKey = chainKeys.solana.privateKey;
|
|
7557
|
+
chainExtras[nodeType].solanaDerivationPath = chainKeys.solana.path;
|
|
7558
|
+
}
|
|
7559
|
+
if (nodeType === "mill" && chainKeys.mina) {
|
|
7560
|
+
chainExtras[nodeType].minaAddress = chainKeys.mina.publicKey;
|
|
7561
|
+
}
|
|
7562
|
+
} catch (err) {
|
|
7563
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
7564
|
+
console.warn(
|
|
7565
|
+
`[WalletManager] deriveMillKeys failed for ${nodeType} (accountIndex=${accountIndex}): ${errMsg} \u2014 chain addresses omitted`
|
|
7566
|
+
);
|
|
5684
7567
|
}
|
|
5685
|
-
} catch {
|
|
5686
7568
|
}
|
|
5687
7569
|
const keys = {
|
|
5688
|
-
town: this.deriveNodeKeys(seed, "town"),
|
|
5689
|
-
mill: { ...
|
|
5690
|
-
dvm: this.deriveNodeKeys(seed, "dvm")
|
|
7570
|
+
town: { ...this.deriveNodeKeys(seed, "town"), ...chainExtras.town },
|
|
7571
|
+
mill: { ...this.deriveNodeKeys(seed, "mill"), ...chainExtras.mill },
|
|
7572
|
+
dvm: { ...this.deriveNodeKeys(seed, "dvm"), ...chainExtras.dvm }
|
|
5691
7573
|
};
|
|
5692
|
-
return { keys };
|
|
7574
|
+
return { keys, mnemonic };
|
|
5693
7575
|
} finally {
|
|
5694
7576
|
if (seed) seed.fill(0);
|
|
5695
7577
|
}
|
|
5696
7578
|
}
|
|
5697
7579
|
/**
|
|
5698
7580
|
* Derive Nostr + EVM keys for a specific node type.
|
|
7581
|
+
* Accepts an optional `accountIndex` to override the default per-type index.
|
|
7582
|
+
* When omitted, uses `NODE_ACCOUNT_INDEX[nodeType]` (existing behavior).
|
|
5699
7583
|
*/
|
|
5700
|
-
deriveNodeKeys(seed, nodeType) {
|
|
5701
|
-
const
|
|
5702
|
-
const nostrPath = `m/44'/1237'/${
|
|
7584
|
+
deriveNodeKeys(seed, nodeType, accountIndex) {
|
|
7585
|
+
const idx = accountIndex ?? NODE_ACCOUNT_INDEX[nodeType];
|
|
7586
|
+
const nostrPath = `m/44'/1237'/${idx}'/0/0`;
|
|
5703
7587
|
const nostrHdKey = HDKey.fromMasterSeed(seed).derive(nostrPath);
|
|
5704
7588
|
if (!nostrHdKey.privateKey) {
|
|
5705
7589
|
throw new Error(`Nostr private key missing at ${nostrPath}`);
|
|
5706
7590
|
}
|
|
5707
7591
|
const nostrSecretKey = new Uint8Array(nostrHdKey.privateKey);
|
|
5708
7592
|
const nostrPubkey = getPublicKey(nostrSecretKey);
|
|
5709
|
-
const evmPath = `m/44'/60'/${
|
|
7593
|
+
const evmPath = `m/44'/60'/${idx}'/0/0`;
|
|
5710
7594
|
const evmHdKey = HDKey.fromMasterSeed(seed).derive(evmPath);
|
|
5711
7595
|
if (!evmHdKey.privateKey) {
|
|
5712
7596
|
throw new Error(`EVM private key missing at ${evmPath}`);
|
|
@@ -5723,6 +7607,54 @@ var WalletManager = class {
|
|
|
5723
7607
|
};
|
|
5724
7608
|
}
|
|
5725
7609
|
};
|
|
7610
|
+
async function deriveArweaveKey(seed, accountIndex) {
|
|
7611
|
+
const path = `m/44'/472'/${accountIndex}'/0/0`;
|
|
7612
|
+
const hdKey = HDKey.fromMasterSeed(seed).derive(path);
|
|
7613
|
+
if (!hdKey.privateKey) {
|
|
7614
|
+
throw new Error(`Arweave sub-seed missing at ${path}`);
|
|
7615
|
+
}
|
|
7616
|
+
const subSeed = new Uint8Array(hdKey.privateKey);
|
|
7617
|
+
const { rsaPrivateKeyPemFromSeed } = await import("./rsa-from-seed-VMNLNDZM.js");
|
|
7618
|
+
let pemPrivateKey;
|
|
7619
|
+
try {
|
|
7620
|
+
pemPrivateKey = await rsaPrivateKeyPemFromSeed(subSeed);
|
|
7621
|
+
} finally {
|
|
7622
|
+
subSeed.fill(0);
|
|
7623
|
+
}
|
|
7624
|
+
const keyObject = createPrivateKey({
|
|
7625
|
+
key: pemPrivateKey,
|
|
7626
|
+
format: "pem",
|
|
7627
|
+
type: "pkcs1"
|
|
7628
|
+
});
|
|
7629
|
+
const rawJwk = keyObject.export({ format: "jwk" });
|
|
7630
|
+
if (rawJwk.kty !== "RSA" || !rawJwk.n || !rawJwk.e || !rawJwk.d || !rawJwk.p || !rawJwk.q || !rawJwk.dp || !rawJwk.dq || !rawJwk.qi) {
|
|
7631
|
+
throw new Error(
|
|
7632
|
+
`Arweave JWK conversion produced unexpected shape (kty=${String(rawJwk.kty)}, has-private=${Boolean(rawJwk.d)})`
|
|
7633
|
+
);
|
|
7634
|
+
}
|
|
7635
|
+
const jwk = {
|
|
7636
|
+
kty: "RSA",
|
|
7637
|
+
e: rawJwk.e,
|
|
7638
|
+
n: rawJwk.n,
|
|
7639
|
+
d: rawJwk.d,
|
|
7640
|
+
p: rawJwk.p,
|
|
7641
|
+
q: rawJwk.q,
|
|
7642
|
+
dp: rawJwk.dp,
|
|
7643
|
+
dq: rawJwk.dq,
|
|
7644
|
+
qi: rawJwk.qi
|
|
7645
|
+
};
|
|
7646
|
+
const modulusBytes = Buffer.from(jwk.n, "base64url");
|
|
7647
|
+
const address = createHash("sha256").update(modulusBytes).digest("base64url");
|
|
7648
|
+
return { jwk, address, path };
|
|
7649
|
+
}
|
|
7650
|
+
function zeroArweaveJwk(jwk) {
|
|
7651
|
+
jwk.d = "";
|
|
7652
|
+
jwk.p = "";
|
|
7653
|
+
jwk.q = "";
|
|
7654
|
+
jwk.dp = "";
|
|
7655
|
+
jwk.dq = "";
|
|
7656
|
+
jwk.qi = "";
|
|
7657
|
+
}
|
|
5726
7658
|
function computeEvmAddress(privateKey) {
|
|
5727
7659
|
const uncompressed = secp256k1.getPublicKey(privateKey, false);
|
|
5728
7660
|
const hash = keccak_256(uncompressed.slice(1));
|
|
@@ -5741,6 +7673,265 @@ function toChecksumAddress(addressHex) {
|
|
|
5741
7673
|
return out;
|
|
5742
7674
|
}
|
|
5743
7675
|
|
|
7676
|
+
// src/earnings/aggregator.ts
|
|
7677
|
+
var STUB_DELTAS = { today: "0", month: "0", year: "0" };
|
|
7678
|
+
async function maybeDeltas(deltaComputer, scope, assetCode, currentLifetime, logger) {
|
|
7679
|
+
if (!deltaComputer) return { ...STUB_DELTAS };
|
|
7680
|
+
try {
|
|
7681
|
+
return await deltaComputer({ scope, assetCode, currentLifetime });
|
|
7682
|
+
} catch (err) {
|
|
7683
|
+
logger?.warn(
|
|
7684
|
+
{ err, scope, assetCode },
|
|
7685
|
+
"aggregator: deltaComputer rejected \u2014 stubbing deltas to 0 for this asset"
|
|
7686
|
+
);
|
|
7687
|
+
return { ...STUB_DELTAS };
|
|
7688
|
+
}
|
|
7689
|
+
}
|
|
7690
|
+
async function aggregateEarnings(input) {
|
|
7691
|
+
let earnings;
|
|
7692
|
+
try {
|
|
7693
|
+
earnings = await input.connectorAdmin.getEarnings();
|
|
7694
|
+
} catch (err) {
|
|
7695
|
+
input.logger?.warn(
|
|
7696
|
+
{ err },
|
|
7697
|
+
"aggregator: connectorAdmin.getEarnings failed \u2014 returning status=connector_unavailable"
|
|
7698
|
+
);
|
|
7699
|
+
return {
|
|
7700
|
+
status: "connector_unavailable",
|
|
7701
|
+
apex: { routingFees: {} },
|
|
7702
|
+
peers: [],
|
|
7703
|
+
recentClaims: [],
|
|
7704
|
+
eventsRelayed: 0,
|
|
7705
|
+
uptimeSeconds: 0
|
|
7706
|
+
};
|
|
7707
|
+
}
|
|
7708
|
+
const buildRoutingFees = async () => {
|
|
7709
|
+
const out = {};
|
|
7710
|
+
await Promise.all(
|
|
7711
|
+
earnings.connectorFees.map(async (fee) => {
|
|
7712
|
+
const deltas = await maybeDeltas(
|
|
7713
|
+
input.deltaComputer,
|
|
7714
|
+
"__apex__",
|
|
7715
|
+
fee.assetCode,
|
|
7716
|
+
fee.total,
|
|
7717
|
+
input.logger
|
|
7718
|
+
);
|
|
7719
|
+
out[fee.assetCode] = { lifetime: fee.total, ...deltas };
|
|
7720
|
+
})
|
|
7721
|
+
);
|
|
7722
|
+
return out;
|
|
7723
|
+
};
|
|
7724
|
+
const buildPeers = async () => Promise.all(
|
|
7725
|
+
earnings.peers.map(async (peer) => {
|
|
7726
|
+
const type = input.peerTypeResolver.resolvePeerType(peer.peerId);
|
|
7727
|
+
const byAsset = {};
|
|
7728
|
+
await Promise.all(
|
|
7729
|
+
peer.byAsset.map(async (a) => {
|
|
7730
|
+
const deltas = await maybeDeltas(
|
|
7731
|
+
input.deltaComputer,
|
|
7732
|
+
peer.peerId,
|
|
7733
|
+
a.assetCode,
|
|
7734
|
+
a.claimsReceivedTotal,
|
|
7735
|
+
input.logger
|
|
7736
|
+
);
|
|
7737
|
+
byAsset[a.assetCode] = {
|
|
7738
|
+
lifetime: a.claimsReceivedTotal,
|
|
7739
|
+
...deltas
|
|
7740
|
+
};
|
|
7741
|
+
})
|
|
7742
|
+
);
|
|
7743
|
+
const lastClaimAt = peer.byAsset.reduce((acc, a) => {
|
|
7744
|
+
const v = a.lastClaimAt;
|
|
7745
|
+
if (!v) return acc;
|
|
7746
|
+
const vMs = Date.parse(v);
|
|
7747
|
+
if (!Number.isFinite(vMs)) return acc;
|
|
7748
|
+
if (acc === null) return v;
|
|
7749
|
+
const accMs = Date.parse(acc);
|
|
7750
|
+
if (!Number.isFinite(accMs)) return v;
|
|
7751
|
+
return vMs > accMs ? v : acc;
|
|
7752
|
+
}, null);
|
|
7753
|
+
return { id: peer.peerId, type, byAsset, lastClaimAt };
|
|
7754
|
+
})
|
|
7755
|
+
);
|
|
7756
|
+
const metricsPromise = input.connectorAdmin.getMetrics().catch((err) => {
|
|
7757
|
+
input.logger?.warn(
|
|
7758
|
+
{ err },
|
|
7759
|
+
"aggregator: getMetrics failed \u2014 eventsRelayed/uptimeSeconds defaulting to 0"
|
|
7760
|
+
);
|
|
7761
|
+
return null;
|
|
7762
|
+
});
|
|
7763
|
+
const [routingFees, peers, metricsResult] = await Promise.all([
|
|
7764
|
+
buildRoutingFees(),
|
|
7765
|
+
buildPeers(),
|
|
7766
|
+
metricsPromise
|
|
7767
|
+
]);
|
|
7768
|
+
const clampInt = (n) => Number.isFinite(n) && n >= 0 ? Math.floor(n) : 0;
|
|
7769
|
+
let eventsRelayed = 0;
|
|
7770
|
+
if (metricsResult) {
|
|
7771
|
+
if (metricsResult.peers.length === 0) {
|
|
7772
|
+
eventsRelayed = clampInt(metricsResult.aggregate.packetsForwarded) + clampInt(metricsResult.aggregate.packetsLocallyDelivered ?? 0);
|
|
7773
|
+
} else {
|
|
7774
|
+
eventsRelayed = metricsResult.peers.reduce(
|
|
7775
|
+
(sum, p) => sum + clampInt(p.packetsForwarded) + clampInt(p.packetsLocallyDelivered ?? 0),
|
|
7776
|
+
0
|
|
7777
|
+
);
|
|
7778
|
+
}
|
|
7779
|
+
}
|
|
7780
|
+
const uptimeSeconds = clampInt(metricsResult?.uptimeSeconds ?? 0);
|
|
7781
|
+
return {
|
|
7782
|
+
status: "ok",
|
|
7783
|
+
apex: { routingFees },
|
|
7784
|
+
peers,
|
|
7785
|
+
recentClaims: earnings.recentClaims,
|
|
7786
|
+
eventsRelayed,
|
|
7787
|
+
uptimeSeconds
|
|
7788
|
+
};
|
|
7789
|
+
}
|
|
7790
|
+
|
|
7791
|
+
// src/earnings/snapshot-reader.ts
|
|
7792
|
+
import { createReadStream } from "fs";
|
|
7793
|
+
import { createInterface } from "readline";
|
|
7794
|
+
function utcDayBoundary(ref) {
|
|
7795
|
+
const d = new Date(ref);
|
|
7796
|
+
d.setUTCHours(0, 0, 0, 0);
|
|
7797
|
+
return d.toISOString();
|
|
7798
|
+
}
|
|
7799
|
+
function utcMonthBoundary(ref) {
|
|
7800
|
+
const d = new Date(ref);
|
|
7801
|
+
d.setUTCDate(1);
|
|
7802
|
+
d.setUTCHours(0, 0, 0, 0);
|
|
7803
|
+
return d.toISOString();
|
|
7804
|
+
}
|
|
7805
|
+
function utcYearBoundary(ref) {
|
|
7806
|
+
const d = new Date(ref);
|
|
7807
|
+
d.setUTCMonth(0, 1);
|
|
7808
|
+
d.setUTCHours(0, 0, 0, 0);
|
|
7809
|
+
return d.toISOString();
|
|
7810
|
+
}
|
|
7811
|
+
async function readSnapshotMap(snapshotPath, nowMs) {
|
|
7812
|
+
const map = /* @__PURE__ */ new Map();
|
|
7813
|
+
let stream;
|
|
7814
|
+
try {
|
|
7815
|
+
stream = createReadStream(snapshotPath, { encoding: "utf-8" });
|
|
7816
|
+
} catch {
|
|
7817
|
+
return map;
|
|
7818
|
+
}
|
|
7819
|
+
let streamFailed = false;
|
|
7820
|
+
await new Promise((resolve2) => {
|
|
7821
|
+
const rl = createInterface({ input: stream, crlfDelay: Infinity });
|
|
7822
|
+
rl.on("line", (line) => {
|
|
7823
|
+
if (!line.trim()) return;
|
|
7824
|
+
let entry;
|
|
7825
|
+
try {
|
|
7826
|
+
entry = JSON.parse(line);
|
|
7827
|
+
} catch {
|
|
7828
|
+
return;
|
|
7829
|
+
}
|
|
7830
|
+
if (!isSnapshotEntry2(entry)) return;
|
|
7831
|
+
const tsMs = Date.parse(entry.ts);
|
|
7832
|
+
if (!Number.isFinite(tsMs)) return;
|
|
7833
|
+
if (tsMs > nowMs) return;
|
|
7834
|
+
const key = `${entry.peerId}\0${entry.assetCode}`;
|
|
7835
|
+
let arr = map.get(key);
|
|
7836
|
+
if (!arr) {
|
|
7837
|
+
arr = [];
|
|
7838
|
+
map.set(key, arr);
|
|
7839
|
+
}
|
|
7840
|
+
arr.push(entry);
|
|
7841
|
+
});
|
|
7842
|
+
rl.on("close", resolve2);
|
|
7843
|
+
rl.on("error", () => {
|
|
7844
|
+
streamFailed = true;
|
|
7845
|
+
resolve2();
|
|
7846
|
+
});
|
|
7847
|
+
stream.on("error", () => {
|
|
7848
|
+
streamFailed = true;
|
|
7849
|
+
rl.close();
|
|
7850
|
+
resolve2();
|
|
7851
|
+
});
|
|
7852
|
+
});
|
|
7853
|
+
if (streamFailed) return /* @__PURE__ */ new Map();
|
|
7854
|
+
return map;
|
|
7855
|
+
}
|
|
7856
|
+
function findBestMatch(entries, boundaryMs) {
|
|
7857
|
+
if (!entries || entries.length === 0) return null;
|
|
7858
|
+
let best = null;
|
|
7859
|
+
let bestMs = -Infinity;
|
|
7860
|
+
for (const e of entries) {
|
|
7861
|
+
const eMs = Date.parse(e.ts);
|
|
7862
|
+
if (!Number.isFinite(eMs)) continue;
|
|
7863
|
+
if (eMs <= boundaryMs && eMs > bestMs) {
|
|
7864
|
+
best = e;
|
|
7865
|
+
bestMs = eMs;
|
|
7866
|
+
}
|
|
7867
|
+
}
|
|
7868
|
+
return best;
|
|
7869
|
+
}
|
|
7870
|
+
function createDeltaComputer(opts) {
|
|
7871
|
+
return async ({ scope, assetCode, currentLifetime }) => {
|
|
7872
|
+
const ref = (opts.now ?? (() => /* @__PURE__ */ new Date()))();
|
|
7873
|
+
const nowMs = ref.getTime();
|
|
7874
|
+
if (!Number.isFinite(nowMs)) {
|
|
7875
|
+
return { today: "0", month: "0", year: "0" };
|
|
7876
|
+
}
|
|
7877
|
+
const dayMs = Date.parse(utcDayBoundary(ref));
|
|
7878
|
+
const monthMs = Date.parse(utcMonthBoundary(ref));
|
|
7879
|
+
const yearMs = Date.parse(utcYearBoundary(ref));
|
|
7880
|
+
const map = await readSnapshotMap(opts.snapshotPath, nowMs);
|
|
7881
|
+
const key = `${scope}\0${assetCode}`;
|
|
7882
|
+
const entries = map.get(key);
|
|
7883
|
+
const daySnap = findBestMatch(entries, dayMs);
|
|
7884
|
+
const monthSnap = findBestMatch(entries, monthMs);
|
|
7885
|
+
const yearSnap = findBestMatch(entries, yearMs);
|
|
7886
|
+
let cur;
|
|
7887
|
+
try {
|
|
7888
|
+
cur = BigInt(currentLifetime);
|
|
7889
|
+
} catch {
|
|
7890
|
+
return { today: "0", month: "0", year: "0" };
|
|
7891
|
+
}
|
|
7892
|
+
const subOrZero = (snap) => {
|
|
7893
|
+
if (!snap) return "0";
|
|
7894
|
+
try {
|
|
7895
|
+
const base = BigInt(snap.claimsReceivedTotal);
|
|
7896
|
+
if (base < 0n) return "0";
|
|
7897
|
+
const diff = cur - base;
|
|
7898
|
+
return diff < 0n ? "0" : diff.toString();
|
|
7899
|
+
} catch {
|
|
7900
|
+
return "0";
|
|
7901
|
+
}
|
|
7902
|
+
};
|
|
7903
|
+
return {
|
|
7904
|
+
today: subOrZero(daySnap),
|
|
7905
|
+
month: subOrZero(monthSnap),
|
|
7906
|
+
year: subOrZero(yearSnap)
|
|
7907
|
+
};
|
|
7908
|
+
};
|
|
7909
|
+
}
|
|
7910
|
+
function isSnapshotEntry2(v) {
|
|
7911
|
+
if (typeof v !== "object" || v === null) return false;
|
|
7912
|
+
const e = v;
|
|
7913
|
+
return typeof e["ts"] === "string" && typeof e["peerId"] === "string" && typeof e["assetCode"] === "string" && typeof e["claimsReceivedTotal"] === "string";
|
|
7914
|
+
}
|
|
7915
|
+
|
|
7916
|
+
// src/registry/peer-type-resolver.ts
|
|
7917
|
+
var PeerTypeResolver = class {
|
|
7918
|
+
map;
|
|
7919
|
+
constructor(yaml) {
|
|
7920
|
+
this.map = new Map(yaml.entries.map((e) => [e.peerId, e.type]));
|
|
7921
|
+
}
|
|
7922
|
+
/**
|
|
7923
|
+
* Resolve a connector `peerId` to its operator-declared node type.
|
|
7924
|
+
* Returns `'external'` for unknown peerIds (legitimate non-Townhouse
|
|
7925
|
+
* peers running through the same connector).
|
|
7926
|
+
*/
|
|
7927
|
+
resolvePeerType(peerId) {
|
|
7928
|
+
return this.map.get(peerId) ?? "external";
|
|
7929
|
+
}
|
|
7930
|
+
};
|
|
7931
|
+
|
|
7932
|
+
// src/api/server.ts
|
|
7933
|
+
import { join as join8, dirname as dirname11 } from "path";
|
|
7934
|
+
|
|
5744
7935
|
// ../../node_modules/.pnpm/ws@8.19.0/node_modules/ws/wrapper.mjs
|
|
5745
7936
|
var import_stream = __toESM(require_stream(), 1);
|
|
5746
7937
|
var import_receiver = __toESM(require_receiver(), 1);
|
|
@@ -5752,6 +7943,7 @@ var import_websocket_server = __toESM(require_websocket_server(), 1);
|
|
|
5752
7943
|
import Fastify from "fastify";
|
|
5753
7944
|
import cors from "@fastify/cors";
|
|
5754
7945
|
import websocket from "@fastify/websocket";
|
|
7946
|
+
import { createRequire as nodeCreateRequire } from "module";
|
|
5755
7947
|
|
|
5756
7948
|
// src/api/cors.ts
|
|
5757
7949
|
var ALLOWED_ORIGINS = ["localhost", "127.0.0.1", "[::1]", "::1"];
|
|
@@ -5781,6 +7973,20 @@ function buildCorsOptions() {
|
|
|
5781
7973
|
}
|
|
5782
7974
|
|
|
5783
7975
|
// src/api/build-app.ts
|
|
7976
|
+
var STARTED_AT = (/* @__PURE__ */ new Date()).toISOString();
|
|
7977
|
+
var _localRequire = nodeCreateRequire(import.meta.url);
|
|
7978
|
+
function _loadPackageJson() {
|
|
7979
|
+
for (const rel of ["../package.json", "../../package.json"]) {
|
|
7980
|
+
try {
|
|
7981
|
+
return _localRequire(rel);
|
|
7982
|
+
} catch {
|
|
7983
|
+
}
|
|
7984
|
+
}
|
|
7985
|
+
throw new Error(
|
|
7986
|
+
"build-app.ts: could not resolve package.json from '../package.json' or '../../package.json'. Bundle layout may have changed \u2014 update the resolution ladder."
|
|
7987
|
+
);
|
|
7988
|
+
}
|
|
7989
|
+
var _pkgVersion = _loadPackageJson()["version"];
|
|
5784
7990
|
var LOOPBACK_HOSTS = ["127.0.0.1", "::1", "localhost"];
|
|
5785
7991
|
async function buildFastifyApp(opts = {}) {
|
|
5786
7992
|
const bindHost = opts.bindHost ?? "127.0.0.1";
|
|
@@ -5806,7 +8012,21 @@ async function buildFastifyApp(opts = {}) {
|
|
|
5806
8012
|
"res.body.mnemonic",
|
|
5807
8013
|
"mnemonic",
|
|
5808
8014
|
"password",
|
|
5809
|
-
"password_confirm"
|
|
8015
|
+
"password_confirm",
|
|
8016
|
+
// Story 46.2: secret-bearing fields introduced by node lifecycle
|
|
8017
|
+
// routes. These never appear in request/response bodies (they go
|
|
8018
|
+
// to subprocess env), but defense-in-depth covers them at every
|
|
8019
|
+
// path Pino might log a stray object (error objects, debug dumps).
|
|
8020
|
+
"nostrSecretKey",
|
|
8021
|
+
"evmPrivateKey",
|
|
8022
|
+
"TOWN_SECRET_KEY",
|
|
8023
|
+
"MILL_SECRET_KEY",
|
|
8024
|
+
"DVM_SECRET_KEY",
|
|
8025
|
+
"TOWN_SETTLEMENT_PRIVATE_KEY",
|
|
8026
|
+
"MILL_SETTLEMENT_PRIVATE_KEY",
|
|
8027
|
+
"DVM_SETTLEMENT_PRIVATE_KEY",
|
|
8028
|
+
"MILL_MNEMONIC",
|
|
8029
|
+
"TOWNHOUSE_WALLET_PASSWORD"
|
|
5810
8030
|
],
|
|
5811
8031
|
censor: "[REDACTED]"
|
|
5812
8032
|
}
|
|
@@ -5845,6 +8065,12 @@ async function buildFastifyApp(opts = {}) {
|
|
|
5845
8065
|
});
|
|
5846
8066
|
await app.register(cors, buildCorsOptions());
|
|
5847
8067
|
await app.register(websocket);
|
|
8068
|
+
app.get("/health", async () => ({
|
|
8069
|
+
status: "healthy",
|
|
8070
|
+
uptime: Math.floor(process.uptime()),
|
|
8071
|
+
startedAt: STARTED_AT,
|
|
8072
|
+
version: _pkgVersion
|
|
8073
|
+
}));
|
|
5848
8074
|
return app;
|
|
5849
8075
|
}
|
|
5850
8076
|
|
|
@@ -7131,6 +9357,15 @@ function acquireConfigMutex() {
|
|
|
7131
9357
|
function releaseConfigMutex() {
|
|
7132
9358
|
isMutating = false;
|
|
7133
9359
|
}
|
|
9360
|
+
var isNodeLifecycleRunning = false;
|
|
9361
|
+
function acquireNodeLifecycleMutex() {
|
|
9362
|
+
if (isNodeLifecycleRunning) return false;
|
|
9363
|
+
isNodeLifecycleRunning = true;
|
|
9364
|
+
return true;
|
|
9365
|
+
}
|
|
9366
|
+
function releaseNodeLifecycleMutex() {
|
|
9367
|
+
isNodeLifecycleRunning = false;
|
|
9368
|
+
}
|
|
7134
9369
|
|
|
7135
9370
|
// src/api/routes/nodes-patch.ts
|
|
7136
9371
|
var patchBodySchema = {
|
|
@@ -7191,61 +9426,773 @@ function registerConfigPatchRoutes(app, deps) {
|
|
|
7191
9426
|
type
|
|
7192
9427
|
});
|
|
7193
9428
|
}
|
|
7194
|
-
const existingKindPricing = nodeConfig.kindPricing ?? void 0;
|
|
7195
|
-
const mergedKindPricing = body.kindPricing !== void 0 ? { ...existingKindPricing ?? {}, ...body.kindPricing } : existingKindPricing;
|
|
7196
|
-
const mergedConfig = {
|
|
7197
|
-
...currentConfig,
|
|
7198
|
-
nodes: {
|
|
7199
|
-
...currentConfig.nodes,
|
|
7200
|
-
[type]: {
|
|
7201
|
-
...nodeConfig,
|
|
7202
|
-
...body,
|
|
7203
|
-
...mergedKindPricing !== void 0 ? { kindPricing: mergedKindPricing } : {}
|
|
7204
|
-
}
|
|
9429
|
+
const existingKindPricing = nodeConfig.kindPricing ?? void 0;
|
|
9430
|
+
const mergedKindPricing = body.kindPricing !== void 0 ? { ...existingKindPricing ?? {}, ...body.kindPricing } : existingKindPricing;
|
|
9431
|
+
const mergedConfig = {
|
|
9432
|
+
...currentConfig,
|
|
9433
|
+
nodes: {
|
|
9434
|
+
...currentConfig.nodes,
|
|
9435
|
+
[type]: {
|
|
9436
|
+
...nodeConfig,
|
|
9437
|
+
...body,
|
|
9438
|
+
...mergedKindPricing !== void 0 ? { kindPricing: mergedKindPricing } : {}
|
|
9439
|
+
}
|
|
9440
|
+
}
|
|
9441
|
+
};
|
|
9442
|
+
try {
|
|
9443
|
+
validateConfig(mergedConfig);
|
|
9444
|
+
} catch (validationError) {
|
|
9445
|
+
return reply.status(400).send({
|
|
9446
|
+
error: "config_validation_error",
|
|
9447
|
+
message: validationError instanceof Error ? validationError.message : "Invalid configuration"
|
|
9448
|
+
});
|
|
9449
|
+
}
|
|
9450
|
+
await saveConfig(deps.configPath, mergedConfig);
|
|
9451
|
+
deps.config.nodes = mergedConfig.nodes;
|
|
9452
|
+
const nodeType = type;
|
|
9453
|
+
const oldEnabled = nodeConfig.enabled;
|
|
9454
|
+
const newEnabled = body.enabled !== void 0 ? body.enabled : oldEnabled;
|
|
9455
|
+
if (oldEnabled !== newEnabled) {
|
|
9456
|
+
if (newEnabled) {
|
|
9457
|
+
await deps.orchestrator.addNode(nodeType);
|
|
9458
|
+
} else {
|
|
9459
|
+
await deps.orchestrator.removeNode(nodeType);
|
|
9460
|
+
}
|
|
9461
|
+
}
|
|
9462
|
+
if (body.feePerEvent !== void 0 || body.feeBasisPoints !== void 0 || body.feePerJob !== void 0 || body.kindPricing !== void 0) {
|
|
9463
|
+
const activeTypes = Object.entries(mergedConfig.nodes).filter(([, config]) => config.enabled).map(([type2]) => type2);
|
|
9464
|
+
await deps.orchestrator.regenerateConnectorConfig(activeTypes);
|
|
9465
|
+
}
|
|
9466
|
+
const u = mergedConfig.nodes[type];
|
|
9467
|
+
if (nodeType === "town") {
|
|
9468
|
+
return { enabled: u.enabled, feePerEvent: u.feePerEvent };
|
|
9469
|
+
} else if (nodeType === "mill") {
|
|
9470
|
+
return { enabled: u.enabled, feeBasisPoints: u.feeBasisPoints };
|
|
9471
|
+
} else {
|
|
9472
|
+
return {
|
|
9473
|
+
enabled: u.enabled,
|
|
9474
|
+
feePerJob: u.feePerJob,
|
|
9475
|
+
kindPricing: u.kindPricing
|
|
9476
|
+
};
|
|
9477
|
+
}
|
|
9478
|
+
} finally {
|
|
9479
|
+
releaseConfigMutex();
|
|
9480
|
+
}
|
|
9481
|
+
}
|
|
9482
|
+
);
|
|
9483
|
+
}
|
|
9484
|
+
|
|
9485
|
+
// src/api/routes/nodes-lifecycle.ts
|
|
9486
|
+
import { promises as fs5 } from "fs";
|
|
9487
|
+
import { dirname as dirname8, join as join5 } from "path";
|
|
9488
|
+
import { bytesToHex as bytesToHex2 } from "@noble/hashes/utils";
|
|
9489
|
+
var HEALTH_PORT = {
|
|
9490
|
+
town: TOWN_HEALTH_PORT,
|
|
9491
|
+
mill: MILL_HEALTH_PORT,
|
|
9492
|
+
dvm: DVM_HEALTH_PORT
|
|
9493
|
+
};
|
|
9494
|
+
var ACCOUNT_INDEX = {
|
|
9495
|
+
town: ACCOUNT_INDEX_TOWN,
|
|
9496
|
+
mill: ACCOUNT_INDEX_MILL,
|
|
9497
|
+
dvm: ACCOUNT_INDEX_DVM
|
|
9498
|
+
};
|
|
9499
|
+
var APEX_ILP_ADDRESS = "g.townhouse";
|
|
9500
|
+
function buildMillSwapPairConfig(config) {
|
|
9501
|
+
const fromChain = config.chainProviders?.[0]?.chainId ?? "evm:base:31337";
|
|
9502
|
+
const toChain = "solana:devnet";
|
|
9503
|
+
return {
|
|
9504
|
+
swapPairs: [
|
|
9505
|
+
{
|
|
9506
|
+
from: { assetCode: "USDC", assetScale: 6, chain: fromChain },
|
|
9507
|
+
to: { assetCode: "USDC", assetScale: 6, chain: toChain },
|
|
9508
|
+
rate: "1.0",
|
|
9509
|
+
minAmount: "1000",
|
|
9510
|
+
maxAmount: "1000000000"
|
|
9511
|
+
}
|
|
9512
|
+
],
|
|
9513
|
+
chains: ["evm", "solana"],
|
|
9514
|
+
// Bootstrap: validateConfig() requires a non-empty channels array for
|
|
9515
|
+
// each distinct pair.to.chain. The zero channelId is a valid-format
|
|
9516
|
+
// sentinel that will never match a real on-chain channel.
|
|
9517
|
+
channels: {
|
|
9518
|
+
[toChain]: [
|
|
9519
|
+
{
|
|
9520
|
+
channelId: "0x" + "0".repeat(64),
|
|
9521
|
+
cumulativeAmount: "0",
|
|
9522
|
+
nonce: "0"
|
|
9523
|
+
}
|
|
9524
|
+
]
|
|
9525
|
+
},
|
|
9526
|
+
// Zero initial SOL inventory; parsed to 0n by the Mill CLI.
|
|
9527
|
+
inventory: {
|
|
9528
|
+
[toChain]: "0"
|
|
9529
|
+
}
|
|
9530
|
+
};
|
|
9531
|
+
}
|
|
9532
|
+
async function waitForHealthy(url, timeoutMs) {
|
|
9533
|
+
const deadline = Date.now() + timeoutMs;
|
|
9534
|
+
const POLL_INTERVAL_MS = 1e3;
|
|
9535
|
+
const REQUEST_TIMEOUT_MS = 3e3;
|
|
9536
|
+
while (Date.now() < deadline) {
|
|
9537
|
+
const controller = new AbortController();
|
|
9538
|
+
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
|
9539
|
+
try {
|
|
9540
|
+
const res = await fetch(url, { signal: controller.signal });
|
|
9541
|
+
if (res.ok) return;
|
|
9542
|
+
} catch {
|
|
9543
|
+
} finally {
|
|
9544
|
+
clearTimeout(timer);
|
|
9545
|
+
}
|
|
9546
|
+
await new Promise((resolve2) => setTimeout(resolve2, POLL_INTERVAL_MS));
|
|
9547
|
+
}
|
|
9548
|
+
throw new Error(
|
|
9549
|
+
`Health check timeout: ${url} did not return 200 within ${timeoutMs}ms`
|
|
9550
|
+
);
|
|
9551
|
+
}
|
|
9552
|
+
function buildNodeEnv(type, nostrSecretKeyHex, evmPrivateKeyHex, mnemonic, apexEvmAddress) {
|
|
9553
|
+
const evmPrivateKeyHex0x = `0x${evmPrivateKeyHex}`;
|
|
9554
|
+
switch (type) {
|
|
9555
|
+
case "town":
|
|
9556
|
+
return {
|
|
9557
|
+
TOWN_SECRET_KEY: nostrSecretKeyHex,
|
|
9558
|
+
TOWN_SETTLEMENT_PRIVATE_KEY: evmPrivateKeyHex0x,
|
|
9559
|
+
APEX_EVM_ADDRESS: apexEvmAddress
|
|
9560
|
+
};
|
|
9561
|
+
case "mill":
|
|
9562
|
+
return {
|
|
9563
|
+
MILL_SECRET_KEY: nostrSecretKeyHex,
|
|
9564
|
+
MILL_SETTLEMENT_PRIVATE_KEY: evmPrivateKeyHex0x,
|
|
9565
|
+
MILL_MNEMONIC: mnemonic ?? "",
|
|
9566
|
+
APEX_EVM_ADDRESS: apexEvmAddress
|
|
9567
|
+
};
|
|
9568
|
+
case "dvm":
|
|
9569
|
+
return {
|
|
9570
|
+
DVM_SECRET_KEY: nostrSecretKeyHex
|
|
9571
|
+
};
|
|
9572
|
+
}
|
|
9573
|
+
}
|
|
9574
|
+
function registerNodeLifecycleRoutes(app, deps) {
|
|
9575
|
+
app.get("/api/nodes", async (request, reply) => {
|
|
9576
|
+
const homeDir = dirname8(deps.configPath);
|
|
9577
|
+
const nodesYamlPath = join5(homeDir, "nodes.yaml");
|
|
9578
|
+
let yaml;
|
|
9579
|
+
try {
|
|
9580
|
+
yaml = await readNodesYaml(nodesYamlPath);
|
|
9581
|
+
} catch (err) {
|
|
9582
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
9583
|
+
request.log.error(
|
|
9584
|
+
{ event: "get_nodes_yaml_error", err: errMsg },
|
|
9585
|
+
"Failed to read nodes.yaml"
|
|
9586
|
+
);
|
|
9587
|
+
return reply.status(500).send({ error: "yaml_read_failed", err: errMsg });
|
|
9588
|
+
}
|
|
9589
|
+
let peers = [];
|
|
9590
|
+
let connectorUnreachable = false;
|
|
9591
|
+
try {
|
|
9592
|
+
peers = await deps.connectorAdmin.getPeers();
|
|
9593
|
+
} catch (err) {
|
|
9594
|
+
connectorUnreachable = true;
|
|
9595
|
+
request.log.warn(
|
|
9596
|
+
{ event: "get_nodes_connector_warn", err: String(err) },
|
|
9597
|
+
"connector unreachable during GET /api/nodes \u2014 returning status:unknown"
|
|
9598
|
+
);
|
|
9599
|
+
}
|
|
9600
|
+
const nodes = yaml.entries.map((entry) => {
|
|
9601
|
+
let status;
|
|
9602
|
+
if (connectorUnreachable) {
|
|
9603
|
+
status = "unknown";
|
|
9604
|
+
} else {
|
|
9605
|
+
const peer = peers.find((p) => p.id === entry.peerId);
|
|
9606
|
+
status = peer?.connected ? "connected" : "disconnected";
|
|
9607
|
+
}
|
|
9608
|
+
return {
|
|
9609
|
+
id: entry.id,
|
|
9610
|
+
type: entry.type,
|
|
9611
|
+
peerId: entry.peerId,
|
|
9612
|
+
ilpAddress: entry.ilpAddress,
|
|
9613
|
+
status,
|
|
9614
|
+
enabledAt: entry.enabledAt,
|
|
9615
|
+
lastSeenAt: entry.lastSeenAt
|
|
9616
|
+
};
|
|
9617
|
+
});
|
|
9618
|
+
return reply.status(200).send({ nodes });
|
|
9619
|
+
});
|
|
9620
|
+
app.post(
|
|
9621
|
+
"/api/nodes",
|
|
9622
|
+
{
|
|
9623
|
+
schema: {
|
|
9624
|
+
body: {
|
|
9625
|
+
type: "object",
|
|
9626
|
+
additionalProperties: false,
|
|
9627
|
+
required: ["type"],
|
|
9628
|
+
properties: {
|
|
9629
|
+
type: { type: "string", enum: ["town", "mill", "dvm"] }
|
|
9630
|
+
}
|
|
9631
|
+
}
|
|
9632
|
+
}
|
|
9633
|
+
},
|
|
9634
|
+
async (request, reply) => {
|
|
9635
|
+
if (!acquireNodeLifecycleMutex()) {
|
|
9636
|
+
return reply.status(409).send({ error: "node_lifecycle_in_flight" });
|
|
9637
|
+
}
|
|
9638
|
+
try {
|
|
9639
|
+
const { type } = request.body;
|
|
9640
|
+
const homeDir = dirname8(deps.configPath);
|
|
9641
|
+
const nodesYamlPath = join5(homeDir, "nodes.yaml");
|
|
9642
|
+
const imageManifestPath = join5(homeDir, "image-manifest.json");
|
|
9643
|
+
const millConfigPath = join5(homeDir, "mill.config.json");
|
|
9644
|
+
const yaml = await readNodesYaml(nodesYamlPath);
|
|
9645
|
+
const existing = yaml.entries.find((e) => e.type === type);
|
|
9646
|
+
if (existing) {
|
|
9647
|
+
return reply.status(409).send({
|
|
9648
|
+
error: "node_type_in_use",
|
|
9649
|
+
type,
|
|
9650
|
+
existingId: existing.id
|
|
9651
|
+
});
|
|
9652
|
+
}
|
|
9653
|
+
if (type === "mill" && !process.env["MILL_RELAYS"]?.trim()) {
|
|
9654
|
+
return reply.status(400).send({
|
|
9655
|
+
step: "preflight",
|
|
9656
|
+
err: "MILL_RELAYS is not set or is blank. Export a comma-separated list of relay URLs before provisioning Mill (e.g. export MILL_RELAYS=wss://relay.example.com). See packages/townhouse/README.md."
|
|
9657
|
+
});
|
|
9658
|
+
}
|
|
9659
|
+
const derivationIndex = ACCOUNT_INDEX[type];
|
|
9660
|
+
const id = type;
|
|
9661
|
+
const peerId = type;
|
|
9662
|
+
const ilpAddress = `${APEX_ILP_ADDRESS}.${type}`;
|
|
9663
|
+
const containerName = `${CONTAINER_PREFIX}hs-${type}`;
|
|
9664
|
+
const healthPort = HEALTH_PORT[type];
|
|
9665
|
+
const healthCheckUrl = `http://${containerName}:${healthPort}/health`;
|
|
9666
|
+
const btpUrl = `ws://${CONTAINER_PREFIX}hs-${type}:${NODE_BTP_PORT}`;
|
|
9667
|
+
request.log.info(
|
|
9668
|
+
{ event: "node_lifecycle_step", step: "derive-key", type, peerId },
|
|
9669
|
+
"Step 1: deriving node key"
|
|
9670
|
+
);
|
|
9671
|
+
let keys;
|
|
9672
|
+
try {
|
|
9673
|
+
keys = await deps.wallet.deriveNodeKey(type, derivationIndex);
|
|
9674
|
+
} catch (err) {
|
|
9675
|
+
const errMsg = sanitizeErrorMessage(
|
|
9676
|
+
err instanceof Error ? err.message : String(err)
|
|
9677
|
+
);
|
|
9678
|
+
request.log.error(
|
|
9679
|
+
{
|
|
9680
|
+
event: "node_lifecycle_failure",
|
|
9681
|
+
step: "derive-key",
|
|
9682
|
+
err: errMsg
|
|
9683
|
+
},
|
|
9684
|
+
"Step 1 failed: derive-key"
|
|
9685
|
+
);
|
|
9686
|
+
return reply.status(500).send({ step: "derive-key", err: errMsg });
|
|
9687
|
+
}
|
|
9688
|
+
const nostrSecretKeyHex = bytesToHex2(keys.nostrSecretKey);
|
|
9689
|
+
const evmPrivateKeyHex = bytesToHex2(keys.evmPrivateKey);
|
|
9690
|
+
const mnemonicSnapshot = deps.wallet.getMnemonic();
|
|
9691
|
+
if (mnemonicSnapshot === null) {
|
|
9692
|
+
const errMsg = "Wallet locked between step 1 and step 4 \u2014 refusing to start container without mnemonic";
|
|
9693
|
+
request.log.error(
|
|
9694
|
+
{
|
|
9695
|
+
event: "node_lifecycle_failure",
|
|
9696
|
+
step: "derive-key",
|
|
9697
|
+
err: errMsg
|
|
9698
|
+
},
|
|
9699
|
+
"Step 1 post-condition failed: mnemonic gone after derive"
|
|
9700
|
+
);
|
|
9701
|
+
return reply.status(500).send({ step: "derive-key", err: errMsg });
|
|
9702
|
+
}
|
|
9703
|
+
const apexEvmAddress = deps.wallet.getNodeKeys("town").evmAddress;
|
|
9704
|
+
request.log.info(
|
|
9705
|
+
{ event: "node_lifecycle_step", step: "pull-image", type, peerId },
|
|
9706
|
+
"Step 2: pulling image"
|
|
9707
|
+
);
|
|
9708
|
+
try {
|
|
9709
|
+
const manifest = await readImageManifest(imageManifestPath);
|
|
9710
|
+
const entry = manifest.images[type];
|
|
9711
|
+
if (isSyntheticDigest(entry.digest)) {
|
|
9712
|
+
return reply.status(400).send({
|
|
9713
|
+
step: "pull-image",
|
|
9714
|
+
err: `Synthetic-digest manifest: image-manifest.json was produced by the connector-publish-smoke workflow for smoke testing only. Fetch a real manifest via 'gh run download' or rerun without --skip-fetch before provisioning nodes.`
|
|
9715
|
+
});
|
|
9716
|
+
}
|
|
9717
|
+
const imageRef = `${entry.name}@${entry.digest}`;
|
|
9718
|
+
await deps.orchestrator.pullImage(imageRef);
|
|
9719
|
+
} catch (err) {
|
|
9720
|
+
const errMsg = sanitizeErrorMessage(
|
|
9721
|
+
err instanceof Error ? err.message : String(err)
|
|
9722
|
+
);
|
|
9723
|
+
request.log.error(
|
|
9724
|
+
{
|
|
9725
|
+
event: "node_lifecycle_failure",
|
|
9726
|
+
step: "pull-image",
|
|
9727
|
+
err: errMsg
|
|
9728
|
+
},
|
|
9729
|
+
"Step 2 failed: pull-image"
|
|
9730
|
+
);
|
|
9731
|
+
return reply.status(502).send({ step: "pull-image", err: errMsg });
|
|
9732
|
+
}
|
|
9733
|
+
request.log.info(
|
|
9734
|
+
{ event: "node_lifecycle_step", step: "write-yaml", type, peerId },
|
|
9735
|
+
"Step 3: writing nodes.yaml entry"
|
|
9736
|
+
);
|
|
9737
|
+
const enabledAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
9738
|
+
const newEntry = {
|
|
9739
|
+
id,
|
|
9740
|
+
type,
|
|
9741
|
+
peerId,
|
|
9742
|
+
ilpAddress,
|
|
9743
|
+
derivationIndex,
|
|
9744
|
+
enabledAt,
|
|
9745
|
+
lastSeenAt: null
|
|
9746
|
+
};
|
|
9747
|
+
try {
|
|
9748
|
+
await writeNodesYaml(nodesYamlPath, {
|
|
9749
|
+
entries: [...yaml.entries, newEntry]
|
|
9750
|
+
});
|
|
9751
|
+
} catch (err) {
|
|
9752
|
+
const errMsg = sanitizeErrorMessage(
|
|
9753
|
+
err instanceof Error ? err.message : String(err)
|
|
9754
|
+
);
|
|
9755
|
+
request.log.error(
|
|
9756
|
+
{
|
|
9757
|
+
event: "node_lifecycle_failure",
|
|
9758
|
+
step: "write-yaml",
|
|
9759
|
+
err: errMsg
|
|
9760
|
+
},
|
|
9761
|
+
"Step 3 failed: write-yaml"
|
|
9762
|
+
);
|
|
9763
|
+
return reply.status(500).send({ step: "write-yaml", err: errMsg });
|
|
9764
|
+
}
|
|
9765
|
+
let millConfigWritten = false;
|
|
9766
|
+
if (type === "mill") {
|
|
9767
|
+
try {
|
|
9768
|
+
const defaultMillConfig = JSON.stringify(
|
|
9769
|
+
buildMillSwapPairConfig(deps.config),
|
|
9770
|
+
null,
|
|
9771
|
+
2
|
|
9772
|
+
);
|
|
9773
|
+
await fs5.mkdir(dirname8(millConfigPath), {
|
|
9774
|
+
recursive: true,
|
|
9775
|
+
mode: 448
|
|
9776
|
+
});
|
|
9777
|
+
await fs5.chmod(dirname8(millConfigPath), 448);
|
|
9778
|
+
await fs5.writeFile(millConfigPath, defaultMillConfig, {
|
|
9779
|
+
encoding: "utf-8",
|
|
9780
|
+
mode: 384
|
|
9781
|
+
});
|
|
9782
|
+
millConfigWritten = true;
|
|
9783
|
+
} catch (err) {
|
|
9784
|
+
const errMsg = sanitizeErrorMessage(
|
|
9785
|
+
err instanceof Error ? err.message : String(err)
|
|
9786
|
+
);
|
|
9787
|
+
request.log.error(
|
|
9788
|
+
{
|
|
9789
|
+
event: "node_lifecycle_failure",
|
|
9790
|
+
step: "write-mill-config",
|
|
9791
|
+
err: errMsg
|
|
9792
|
+
},
|
|
9793
|
+
"Step 3b failed: write mill.config.json"
|
|
9794
|
+
);
|
|
9795
|
+
const rollbackMillError = await safeRollbackMillConfig(
|
|
9796
|
+
millConfigPath,
|
|
9797
|
+
request
|
|
9798
|
+
);
|
|
9799
|
+
const rollbackYamlError = await safeRollbackYaml(
|
|
9800
|
+
nodesYamlPath,
|
|
9801
|
+
peerId,
|
|
9802
|
+
request
|
|
9803
|
+
);
|
|
9804
|
+
const rollbackError = combineRollbackErrors(
|
|
9805
|
+
rollbackMillError,
|
|
9806
|
+
rollbackYamlError
|
|
9807
|
+
);
|
|
9808
|
+
return reply.status(500).send({ step: "write-mill-config", err: errMsg, rollbackError });
|
|
9809
|
+
}
|
|
9810
|
+
}
|
|
9811
|
+
request.log.info(
|
|
9812
|
+
{
|
|
9813
|
+
event: "node_lifecycle_step",
|
|
9814
|
+
step: "start-container",
|
|
9815
|
+
type,
|
|
9816
|
+
peerId
|
|
9817
|
+
},
|
|
9818
|
+
"Step 4: starting container via compose"
|
|
9819
|
+
);
|
|
9820
|
+
const nodeEnv = buildNodeEnv(
|
|
9821
|
+
type,
|
|
9822
|
+
nostrSecretKeyHex,
|
|
9823
|
+
evmPrivateKeyHex,
|
|
9824
|
+
mnemonicSnapshot,
|
|
9825
|
+
apexEvmAddress
|
|
9826
|
+
);
|
|
9827
|
+
try {
|
|
9828
|
+
await deps.orchestrator.startNodeViaCompose(type, nodeEnv);
|
|
9829
|
+
} catch (err) {
|
|
9830
|
+
const errMsg = sanitizeErrorMessage(
|
|
9831
|
+
err instanceof Error ? err.message : String(err)
|
|
9832
|
+
);
|
|
9833
|
+
request.log.error(
|
|
9834
|
+
{
|
|
9835
|
+
event: "node_lifecycle_failure",
|
|
9836
|
+
step: "start-container",
|
|
9837
|
+
err: errMsg
|
|
9838
|
+
},
|
|
9839
|
+
"Step 4 failed: start-container"
|
|
9840
|
+
);
|
|
9841
|
+
const rollbackError = await safeRollbackYaml(
|
|
9842
|
+
nodesYamlPath,
|
|
9843
|
+
peerId,
|
|
9844
|
+
request
|
|
9845
|
+
);
|
|
9846
|
+
let rollbackMillError;
|
|
9847
|
+
if (millConfigWritten) {
|
|
9848
|
+
rollbackMillError = await safeRollbackMillConfig(
|
|
9849
|
+
millConfigPath,
|
|
9850
|
+
request
|
|
9851
|
+
);
|
|
9852
|
+
}
|
|
9853
|
+
const combinedRollbackError = combineRollbackErrors(
|
|
9854
|
+
rollbackError,
|
|
9855
|
+
rollbackMillError
|
|
9856
|
+
);
|
|
9857
|
+
return reply.status(502).send({
|
|
9858
|
+
step: "start-container",
|
|
9859
|
+
err: errMsg,
|
|
9860
|
+
rollbackError: combinedRollbackError
|
|
9861
|
+
});
|
|
9862
|
+
}
|
|
9863
|
+
request.log.info(
|
|
9864
|
+
{
|
|
9865
|
+
event: "node_lifecycle_step",
|
|
9866
|
+
step: "healthcheck",
|
|
9867
|
+
type,
|
|
9868
|
+
peerId,
|
|
9869
|
+
healthCheckUrl
|
|
9870
|
+
},
|
|
9871
|
+
"Step 5: waiting for container to become healthy"
|
|
9872
|
+
);
|
|
9873
|
+
try {
|
|
9874
|
+
await waitForHealthy(healthCheckUrl, 6e4);
|
|
9875
|
+
} catch (err) {
|
|
9876
|
+
const errMsg = sanitizeErrorMessage(
|
|
9877
|
+
err instanceof Error ? err.message : String(err)
|
|
9878
|
+
);
|
|
9879
|
+
request.log.error(
|
|
9880
|
+
{
|
|
9881
|
+
event: "node_lifecycle_failure",
|
|
9882
|
+
step: "healthcheck",
|
|
9883
|
+
err: errMsg
|
|
9884
|
+
},
|
|
9885
|
+
"Step 5 failed: healthcheck"
|
|
9886
|
+
);
|
|
9887
|
+
const rollbackYamlError = await safeRollbackYaml(
|
|
9888
|
+
nodesYamlPath,
|
|
9889
|
+
peerId,
|
|
9890
|
+
request
|
|
9891
|
+
);
|
|
9892
|
+
const rollbackStopError = await safeRollbackStop(
|
|
9893
|
+
type,
|
|
9894
|
+
deps.orchestrator,
|
|
9895
|
+
request
|
|
9896
|
+
);
|
|
9897
|
+
let rollbackMillError;
|
|
9898
|
+
if (millConfigWritten) {
|
|
9899
|
+
rollbackMillError = await safeRollbackMillConfig(
|
|
9900
|
+
millConfigPath,
|
|
9901
|
+
request
|
|
9902
|
+
);
|
|
7205
9903
|
}
|
|
7206
|
-
|
|
9904
|
+
const combinedRollbackError = combineRollbackErrors(
|
|
9905
|
+
rollbackYamlError,
|
|
9906
|
+
rollbackStopError,
|
|
9907
|
+
rollbackMillError
|
|
9908
|
+
);
|
|
9909
|
+
return reply.status(502).send({
|
|
9910
|
+
step: "healthcheck",
|
|
9911
|
+
err: errMsg,
|
|
9912
|
+
rollbackError: combinedRollbackError
|
|
9913
|
+
});
|
|
9914
|
+
}
|
|
9915
|
+
request.log.info(
|
|
9916
|
+
{
|
|
9917
|
+
event: "node_lifecycle_step",
|
|
9918
|
+
step: "register-peer",
|
|
9919
|
+
type,
|
|
9920
|
+
peerId,
|
|
9921
|
+
ilpAddress
|
|
9922
|
+
},
|
|
9923
|
+
"Step 6: registering peer with connector"
|
|
9924
|
+
);
|
|
7207
9925
|
try {
|
|
7208
|
-
|
|
7209
|
-
|
|
7210
|
-
|
|
7211
|
-
|
|
7212
|
-
|
|
9926
|
+
await deps.connectorAdmin.registerPeer({
|
|
9927
|
+
id: peerId,
|
|
9928
|
+
url: btpUrl,
|
|
9929
|
+
authToken: "",
|
|
9930
|
+
routes: [{ prefix: ilpAddress, priority: 0 }],
|
|
9931
|
+
// Force direct (non-SOCKS5) BTP dial for this Docker-sibling
|
|
9932
|
+
// peer. The apex connector runs with `transport.type: socks5`
|
|
9933
|
+
// so the .anyone HS can publish; without this override, every
|
|
9934
|
+
// peer dial gets routed through the anon proxy and fails with
|
|
9935
|
+
// `HostUnreachable` on Docker-internal hostnames. Requires
|
|
9936
|
+
// connector >= 3.6.2 (toon-protocol/connector#70). Discovered
|
|
9937
|
+
// by Story 46.4 live gate run (Finding Q, 2026-05-12).
|
|
9938
|
+
transport: "direct"
|
|
9939
|
+
});
|
|
9940
|
+
} catch (err) {
|
|
9941
|
+
const errMsg = sanitizeErrorMessage(
|
|
9942
|
+
err instanceof Error ? err.message : String(err)
|
|
9943
|
+
);
|
|
9944
|
+
request.log.error(
|
|
9945
|
+
{
|
|
9946
|
+
event: "node_lifecycle_failure",
|
|
9947
|
+
step: "register-peer",
|
|
9948
|
+
err: errMsg
|
|
9949
|
+
},
|
|
9950
|
+
"Step 6 failed: register-peer"
|
|
9951
|
+
);
|
|
9952
|
+
const rollbackYamlError = await safeRollbackYaml(
|
|
9953
|
+
nodesYamlPath,
|
|
9954
|
+
peerId,
|
|
9955
|
+
request
|
|
9956
|
+
);
|
|
9957
|
+
const rollbackStopError = await safeRollbackStop(
|
|
9958
|
+
type,
|
|
9959
|
+
deps.orchestrator,
|
|
9960
|
+
request
|
|
9961
|
+
);
|
|
9962
|
+
let rollbackMillError;
|
|
9963
|
+
if (millConfigWritten) {
|
|
9964
|
+
rollbackMillError = await safeRollbackMillConfig(
|
|
9965
|
+
millConfigPath,
|
|
9966
|
+
request
|
|
9967
|
+
);
|
|
9968
|
+
}
|
|
9969
|
+
const combinedRollbackError = combineRollbackErrors(
|
|
9970
|
+
rollbackYamlError,
|
|
9971
|
+
rollbackStopError,
|
|
9972
|
+
rollbackMillError
|
|
9973
|
+
);
|
|
9974
|
+
return reply.status(502).send({
|
|
9975
|
+
step: "register-peer",
|
|
9976
|
+
err: errMsg,
|
|
9977
|
+
rollbackError: combinedRollbackError
|
|
7213
9978
|
});
|
|
7214
9979
|
}
|
|
7215
|
-
|
|
7216
|
-
|
|
7217
|
-
|
|
7218
|
-
|
|
7219
|
-
|
|
7220
|
-
|
|
7221
|
-
|
|
7222
|
-
|
|
7223
|
-
|
|
7224
|
-
|
|
9980
|
+
request.log.info(
|
|
9981
|
+
{ event: "node_lifecycle_success", type, peerId, ilpAddress },
|
|
9982
|
+
"Node provisioned successfully"
|
|
9983
|
+
);
|
|
9984
|
+
return reply.status(201).send({
|
|
9985
|
+
id,
|
|
9986
|
+
type,
|
|
9987
|
+
peerId,
|
|
9988
|
+
ilpAddress,
|
|
9989
|
+
hsRoute: ilpAddress,
|
|
9990
|
+
healthCheckUrl
|
|
9991
|
+
});
|
|
9992
|
+
} finally {
|
|
9993
|
+
releaseNodeLifecycleMutex();
|
|
9994
|
+
}
|
|
9995
|
+
}
|
|
9996
|
+
);
|
|
9997
|
+
app.delete(
|
|
9998
|
+
"/api/nodes/:id",
|
|
9999
|
+
{
|
|
10000
|
+
schema: {
|
|
10001
|
+
params: {
|
|
10002
|
+
type: "object",
|
|
10003
|
+
required: ["id"],
|
|
10004
|
+
properties: {
|
|
10005
|
+
id: {
|
|
10006
|
+
type: "string",
|
|
10007
|
+
minLength: 1,
|
|
10008
|
+
maxLength: 64,
|
|
10009
|
+
pattern: "^[a-z][a-z0-9-]*$"
|
|
10010
|
+
}
|
|
7225
10011
|
}
|
|
7226
10012
|
}
|
|
7227
|
-
|
|
7228
|
-
|
|
7229
|
-
|
|
10013
|
+
}
|
|
10014
|
+
},
|
|
10015
|
+
async (request, reply) => {
|
|
10016
|
+
if (!acquireNodeLifecycleMutex()) {
|
|
10017
|
+
return reply.status(409).send({ error: "node_lifecycle_in_flight" });
|
|
10018
|
+
}
|
|
10019
|
+
try {
|
|
10020
|
+
const { id } = request.params;
|
|
10021
|
+
const homeDir = dirname8(deps.configPath);
|
|
10022
|
+
const nodesYamlPath = join5(homeDir, "nodes.yaml");
|
|
10023
|
+
const millConfigPath = join5(homeDir, "mill.config.json");
|
|
10024
|
+
const yaml = await readNodesYaml(nodesYamlPath);
|
|
10025
|
+
const entry = yaml.entries.find((e) => e.id === id);
|
|
10026
|
+
if (!entry) {
|
|
10027
|
+
return reply.status(404).send({ error: "unknown_node", id });
|
|
10028
|
+
}
|
|
10029
|
+
request.log.info(
|
|
10030
|
+
{
|
|
10031
|
+
event: "node_lifecycle_step",
|
|
10032
|
+
step: "deregister-peer",
|
|
10033
|
+
type: entry.type,
|
|
10034
|
+
peerId: entry.peerId
|
|
10035
|
+
},
|
|
10036
|
+
"DELETE step 1: deregistering peer from connector"
|
|
10037
|
+
);
|
|
10038
|
+
try {
|
|
10039
|
+
await deps.connectorAdmin.removePeer(entry.peerId);
|
|
10040
|
+
} catch (err) {
|
|
10041
|
+
const errMsg = sanitizeErrorMessage(
|
|
10042
|
+
err instanceof Error ? err.message : String(err)
|
|
10043
|
+
);
|
|
10044
|
+
request.log.error(
|
|
10045
|
+
{
|
|
10046
|
+
event: "node_lifecycle_failure",
|
|
10047
|
+
step: "deregister-peer",
|
|
10048
|
+
err: errMsg
|
|
10049
|
+
},
|
|
10050
|
+
"DELETE step 1 failed: deregister-peer"
|
|
10051
|
+
);
|
|
10052
|
+
return reply.status(502).send({ step: "deregister-peer", err: errMsg });
|
|
7230
10053
|
}
|
|
7231
|
-
|
|
7232
|
-
|
|
7233
|
-
|
|
7234
|
-
|
|
7235
|
-
|
|
7236
|
-
|
|
7237
|
-
|
|
7238
|
-
|
|
7239
|
-
|
|
7240
|
-
|
|
7241
|
-
|
|
10054
|
+
request.log.info(
|
|
10055
|
+
{
|
|
10056
|
+
event: "node_lifecycle_step",
|
|
10057
|
+
step: "stop-container",
|
|
10058
|
+
type: entry.type
|
|
10059
|
+
},
|
|
10060
|
+
"DELETE step 2: stopping container"
|
|
10061
|
+
);
|
|
10062
|
+
try {
|
|
10063
|
+
await deps.orchestrator.stopNodeViaCompose(entry.type);
|
|
10064
|
+
} catch (err) {
|
|
10065
|
+
const errMsg = sanitizeErrorMessage(
|
|
10066
|
+
err instanceof Error ? err.message : String(err)
|
|
10067
|
+
);
|
|
10068
|
+
request.log.error(
|
|
10069
|
+
{
|
|
10070
|
+
event: "node_lifecycle_failure",
|
|
10071
|
+
step: "stop-container",
|
|
10072
|
+
err: errMsg
|
|
10073
|
+
},
|
|
10074
|
+
"DELETE step 2 failed: stop-container"
|
|
10075
|
+
);
|
|
10076
|
+
return reply.status(502).send({ step: "stop-container", err: errMsg });
|
|
10077
|
+
}
|
|
10078
|
+
request.log.info(
|
|
10079
|
+
{
|
|
10080
|
+
event: "node_lifecycle_step",
|
|
10081
|
+
step: "remove-yaml",
|
|
10082
|
+
type: entry.type
|
|
10083
|
+
},
|
|
10084
|
+
"DELETE step 3: removing nodes.yaml entry"
|
|
10085
|
+
);
|
|
10086
|
+
try {
|
|
10087
|
+
await writeNodesYaml(nodesYamlPath, {
|
|
10088
|
+
entries: yaml.entries.filter((e) => e.id !== id)
|
|
10089
|
+
});
|
|
10090
|
+
} catch (err) {
|
|
10091
|
+
const errMsg = sanitizeErrorMessage(
|
|
10092
|
+
err instanceof Error ? err.message : String(err)
|
|
10093
|
+
);
|
|
10094
|
+
request.log.error(
|
|
10095
|
+
{
|
|
10096
|
+
event: "node_lifecycle_failure",
|
|
10097
|
+
step: "remove-yaml",
|
|
10098
|
+
err: errMsg
|
|
10099
|
+
},
|
|
10100
|
+
"DELETE step 3 failed: remove-yaml"
|
|
10101
|
+
);
|
|
10102
|
+
return reply.status(500).send({ step: "remove-yaml", err: errMsg });
|
|
10103
|
+
}
|
|
10104
|
+
if (entry.type === "mill") {
|
|
10105
|
+
await fs5.rm(millConfigPath, { force: true });
|
|
7242
10106
|
}
|
|
10107
|
+
request.log.info(
|
|
10108
|
+
{ event: "node_lifecycle_deleted", id, type: entry.type },
|
|
10109
|
+
"Node deprovisioned successfully"
|
|
10110
|
+
);
|
|
10111
|
+
return reply.status(200).send({ id, type: entry.type });
|
|
7243
10112
|
} finally {
|
|
7244
|
-
|
|
10113
|
+
releaseNodeLifecycleMutex();
|
|
7245
10114
|
}
|
|
7246
10115
|
}
|
|
7247
10116
|
);
|
|
7248
10117
|
}
|
|
10118
|
+
async function safeRollbackYaml(nodesYamlPath, addedPeerId, request) {
|
|
10119
|
+
try {
|
|
10120
|
+
const current = await readNodesYaml(nodesYamlPath);
|
|
10121
|
+
const filtered = current.entries.filter((e) => e.peerId !== addedPeerId);
|
|
10122
|
+
await writeNodesYaml(nodesYamlPath, { entries: filtered });
|
|
10123
|
+
return void 0;
|
|
10124
|
+
} catch (err) {
|
|
10125
|
+
const errMsg = sanitizeErrorMessage(
|
|
10126
|
+
err instanceof Error ? err.message : String(err)
|
|
10127
|
+
);
|
|
10128
|
+
request.log.error(
|
|
10129
|
+
{
|
|
10130
|
+
event: "node_lifecycle_rollback_failure",
|
|
10131
|
+
step: "write-yaml",
|
|
10132
|
+
err: errMsg
|
|
10133
|
+
},
|
|
10134
|
+
"Rollback: failed to remove yaml entry \u2014 operator may need to hand-edit nodes.yaml"
|
|
10135
|
+
);
|
|
10136
|
+
return `write-yaml: ${errMsg}`;
|
|
10137
|
+
}
|
|
10138
|
+
}
|
|
10139
|
+
async function safeRollbackMillConfig(millConfigPath, request) {
|
|
10140
|
+
try {
|
|
10141
|
+
await fs5.rm(millConfigPath, { force: true });
|
|
10142
|
+
return void 0;
|
|
10143
|
+
} catch (err) {
|
|
10144
|
+
const errMsg = sanitizeErrorMessage(
|
|
10145
|
+
err instanceof Error ? err.message : String(err)
|
|
10146
|
+
);
|
|
10147
|
+
request.log.error(
|
|
10148
|
+
{
|
|
10149
|
+
event: "node_lifecycle_rollback_failure",
|
|
10150
|
+
step: "remove-mill-config",
|
|
10151
|
+
err: errMsg
|
|
10152
|
+
},
|
|
10153
|
+
"Rollback: failed to remove mill.config.json"
|
|
10154
|
+
);
|
|
10155
|
+
return `remove-mill-config: ${errMsg}`;
|
|
10156
|
+
}
|
|
10157
|
+
}
|
|
10158
|
+
async function safeRollbackStop(type, orchestrator, request) {
|
|
10159
|
+
try {
|
|
10160
|
+
await orchestrator.stopNodeViaCompose(type);
|
|
10161
|
+
return void 0;
|
|
10162
|
+
} catch (err) {
|
|
10163
|
+
const errMsg = sanitizeErrorMessage(
|
|
10164
|
+
err instanceof Error ? err.message : String(err)
|
|
10165
|
+
);
|
|
10166
|
+
request.log.error(
|
|
10167
|
+
{
|
|
10168
|
+
event: "node_lifecycle_rollback_failure",
|
|
10169
|
+
step: "stop-container",
|
|
10170
|
+
err: errMsg
|
|
10171
|
+
},
|
|
10172
|
+
"Rollback: failed to stop container \u2014 operator may need to docker rm by hand"
|
|
10173
|
+
);
|
|
10174
|
+
return `stop-container: ${errMsg}`;
|
|
10175
|
+
}
|
|
10176
|
+
}
|
|
10177
|
+
function combineRollbackErrors(...errors) {
|
|
10178
|
+
const present = errors.filter((e) => e !== void 0);
|
|
10179
|
+
if (present.length === 0) return void 0;
|
|
10180
|
+
return present.join("; ");
|
|
10181
|
+
}
|
|
10182
|
+
var SECRET_KEYS = [
|
|
10183
|
+
"TOWN_SECRET_KEY",
|
|
10184
|
+
"MILL_SECRET_KEY",
|
|
10185
|
+
"DVM_SECRET_KEY",
|
|
10186
|
+
"TOWN_SETTLEMENT_PRIVATE_KEY",
|
|
10187
|
+
"MILL_SETTLEMENT_PRIVATE_KEY",
|
|
10188
|
+
"DVM_SETTLEMENT_PRIVATE_KEY",
|
|
10189
|
+
"MILL_MNEMONIC",
|
|
10190
|
+
"TOWNHOUSE_WALLET_PASSWORD"
|
|
10191
|
+
];
|
|
10192
|
+
var REDACT_RE = new RegExp(`(${SECRET_KEYS.join("|")})=[^\\s"'\\n\\r]+`, "g");
|
|
10193
|
+
function sanitizeErrorMessage(msg) {
|
|
10194
|
+
return msg.replace(REDACT_RE, "$1=[REDACTED]");
|
|
10195
|
+
}
|
|
7249
10196
|
|
|
7250
10197
|
// src/api/routes/metrics-ws.ts
|
|
7251
10198
|
import { decode as decodeToon } from "@toon-format/toon";
|
|
@@ -7528,8 +10475,8 @@ function registerMetricsWsRoutes(app, deps) {
|
|
|
7528
10475
|
}
|
|
7529
10476
|
|
|
7530
10477
|
// src/api/routes/wizard.ts
|
|
7531
|
-
import { existsSync, unlinkSync, chmodSync } from "fs";
|
|
7532
|
-
import { dirname as
|
|
10478
|
+
import { existsSync as existsSync4, unlinkSync, chmodSync as chmodSync3 } from "fs";
|
|
10479
|
+
import { dirname as dirname9, join as join6 } from "path";
|
|
7533
10480
|
import { generateMnemonic as generateMnemonic2, validateMnemonic as validateMnemonic2 } from "@scure/bip39";
|
|
7534
10481
|
import { wordlist as wordlist2 } from "@scure/bip39/wordlists/english.js";
|
|
7535
10482
|
var PROGRESS_BUFFER_MAX = 200;
|
|
@@ -7550,8 +10497,8 @@ function isAllowedWsOrigin(origin) {
|
|
|
7550
10497
|
}
|
|
7551
10498
|
function registerWizardRoutes(app, deps, state, onInit) {
|
|
7552
10499
|
app.get("/wizard/state", async (_request, reply) => {
|
|
7553
|
-
const configExists =
|
|
7554
|
-
const walletExists =
|
|
10500
|
+
const configExists = existsSync4(deps.configPath);
|
|
10501
|
+
const walletExists = existsSync4(deps.walletPath);
|
|
7555
10502
|
const containersRunning = state.mode === "normal";
|
|
7556
10503
|
return reply.status(200).send({
|
|
7557
10504
|
config_exists: configExists,
|
|
@@ -7672,13 +10619,13 @@ function registerWizardRoutes(app, deps, state, onInit) {
|
|
|
7672
10619
|
message: 'transport.mode must be "direct" or "ator".'
|
|
7673
10620
|
});
|
|
7674
10621
|
}
|
|
7675
|
-
if (
|
|
10622
|
+
if (existsSync4(deps.walletPath)) {
|
|
7676
10623
|
return reply.status(409).send({
|
|
7677
10624
|
code: "wallet_already_exists",
|
|
7678
10625
|
message: `A wallet already exists at ${deps.walletPath}. Delete it first.`
|
|
7679
10626
|
});
|
|
7680
10627
|
}
|
|
7681
|
-
if (
|
|
10628
|
+
if (existsSync4(deps.configPath)) {
|
|
7682
10629
|
return reply.status(409).send({
|
|
7683
10630
|
code: "config_already_exists",
|
|
7684
10631
|
message: `A config already exists at ${deps.configPath}. Delete it first.`
|
|
@@ -7691,7 +10638,7 @@ function registerWizardRoutes(app, deps, state, onInit) {
|
|
|
7691
10638
|
const encrypted = encryptWallet(cleanMnemonic, password);
|
|
7692
10639
|
await saveWallet(deps.walletPath, encrypted);
|
|
7693
10640
|
try {
|
|
7694
|
-
|
|
10641
|
+
chmodSync3(deps.walletPath, 384);
|
|
7695
10642
|
} catch {
|
|
7696
10643
|
}
|
|
7697
10644
|
let config;
|
|
@@ -7792,8 +10739,8 @@ function registerWizardRoutes(app, deps, state, onInit) {
|
|
|
7792
10739
|
}
|
|
7793
10740
|
function buildConfigFromRequest(request, configPath) {
|
|
7794
10741
|
const config = getDefaultConfig();
|
|
7795
|
-
const configDir =
|
|
7796
|
-
config.wallet.encrypted_path =
|
|
10742
|
+
const configDir = dirname9(configPath);
|
|
10743
|
+
config.wallet.encrypted_path = join6(configDir, "wallet.enc");
|
|
7797
10744
|
config.nodes.town.enabled = request.nodes.town.enabled;
|
|
7798
10745
|
if (request.nodes.town.enabled && request.nodes.town.feePerEvent !== void 0) {
|
|
7799
10746
|
config.nodes.town.feePerEvent = request.nodes.town.feePerEvent;
|
|
@@ -8014,9 +10961,480 @@ function registerTransportRoutes(app, deps, opts = {}) {
|
|
|
8014
10961
|
);
|
|
8015
10962
|
}
|
|
8016
10963
|
|
|
10964
|
+
// src/api/routes/earnings.ts
|
|
10965
|
+
import { dirname as dirname10, join as join7 } from "path";
|
|
10966
|
+
|
|
10967
|
+
// src/api/schemas/earnings.ts
|
|
10968
|
+
var DECIMAL_STRING_PATTERN = "^-?\\d+$";
|
|
10969
|
+
var perAssetSchema = {
|
|
10970
|
+
type: "object",
|
|
10971
|
+
properties: {
|
|
10972
|
+
lifetime: { type: "string", pattern: DECIMAL_STRING_PATTERN },
|
|
10973
|
+
today: { type: "string", pattern: DECIMAL_STRING_PATTERN },
|
|
10974
|
+
month: { type: "string", pattern: DECIMAL_STRING_PATTERN },
|
|
10975
|
+
year: { type: "string", pattern: DECIMAL_STRING_PATTERN }
|
|
10976
|
+
},
|
|
10977
|
+
required: ["lifetime", "today", "month", "year"]
|
|
10978
|
+
// Open to future connector-derived fields per D2 decision (2026-05-13).
|
|
10979
|
+
};
|
|
10980
|
+
var routingFeesSchema = {
|
|
10981
|
+
type: "object",
|
|
10982
|
+
additionalProperties: perAssetSchema
|
|
10983
|
+
};
|
|
10984
|
+
var peerSchema = {
|
|
10985
|
+
type: "object",
|
|
10986
|
+
properties: {
|
|
10987
|
+
id: { type: "string" },
|
|
10988
|
+
type: {
|
|
10989
|
+
type: "string",
|
|
10990
|
+
enum: ["town", "mill", "dvm", "external"]
|
|
10991
|
+
},
|
|
10992
|
+
byAsset: routingFeesSchema,
|
|
10993
|
+
lastClaimAt: {
|
|
10994
|
+
oneOf: [{ type: "string", format: "date-time" }, { type: "null" }]
|
|
10995
|
+
}
|
|
10996
|
+
},
|
|
10997
|
+
required: ["id", "type", "byAsset", "lastClaimAt"],
|
|
10998
|
+
additionalProperties: false
|
|
10999
|
+
// Townhouse owns the peer shape.
|
|
11000
|
+
};
|
|
11001
|
+
var recentClaimSchema = {
|
|
11002
|
+
type: "object",
|
|
11003
|
+
properties: {
|
|
11004
|
+
peerId: { type: "string" },
|
|
11005
|
+
assetCode: { type: "string" },
|
|
11006
|
+
assetScale: { type: "integer", minimum: 0 },
|
|
11007
|
+
amount: { type: "string", pattern: DECIMAL_STRING_PATTERN },
|
|
11008
|
+
direction: { type: "string", enum: ["inbound", "outbound"] },
|
|
11009
|
+
at: { type: "string", format: "date-time" }
|
|
11010
|
+
},
|
|
11011
|
+
required: [
|
|
11012
|
+
"peerId",
|
|
11013
|
+
"assetCode",
|
|
11014
|
+
"assetScale",
|
|
11015
|
+
"amount",
|
|
11016
|
+
"direction",
|
|
11017
|
+
"at"
|
|
11018
|
+
]
|
|
11019
|
+
// Open to future connector-shipped fields per D2 decision (2026-05-13).
|
|
11020
|
+
};
|
|
11021
|
+
var earningsResponseSchema = {
|
|
11022
|
+
response: {
|
|
11023
|
+
200: {
|
|
11024
|
+
type: "object",
|
|
11025
|
+
properties: {
|
|
11026
|
+
status: { type: "string", enum: ["ok", "connector_unavailable"] },
|
|
11027
|
+
apex: {
|
|
11028
|
+
type: "object",
|
|
11029
|
+
properties: {
|
|
11030
|
+
routingFees: routingFeesSchema
|
|
11031
|
+
},
|
|
11032
|
+
required: ["routingFees"],
|
|
11033
|
+
additionalProperties: false
|
|
11034
|
+
},
|
|
11035
|
+
peers: {
|
|
11036
|
+
type: "array",
|
|
11037
|
+
items: peerSchema
|
|
11038
|
+
},
|
|
11039
|
+
recentClaims: {
|
|
11040
|
+
type: "array",
|
|
11041
|
+
items: recentClaimSchema
|
|
11042
|
+
},
|
|
11043
|
+
eventsRelayed: { type: "integer", minimum: 0 },
|
|
11044
|
+
uptimeSeconds: { type: "integer", minimum: 0 }
|
|
11045
|
+
},
|
|
11046
|
+
required: [
|
|
11047
|
+
"status",
|
|
11048
|
+
"apex",
|
|
11049
|
+
"peers",
|
|
11050
|
+
"recentClaims",
|
|
11051
|
+
"eventsRelayed",
|
|
11052
|
+
"uptimeSeconds"
|
|
11053
|
+
],
|
|
11054
|
+
additionalProperties: false
|
|
11055
|
+
}
|
|
11056
|
+
}
|
|
11057
|
+
};
|
|
11058
|
+
|
|
11059
|
+
// src/api/routes/earnings.ts
|
|
11060
|
+
function resolveNodesYamlPath(deps) {
|
|
11061
|
+
return join7(dirname10(deps.configPath), "nodes.yaml");
|
|
11062
|
+
}
|
|
11063
|
+
function resolveSnapshotPath(deps) {
|
|
11064
|
+
return join7(dirname10(deps.configPath), "earnings-snapshots.jsonl");
|
|
11065
|
+
}
|
|
11066
|
+
function registerEarningsRoutes(app, deps) {
|
|
11067
|
+
app.get(
|
|
11068
|
+
"/api/earnings",
|
|
11069
|
+
{ schema: earningsResponseSchema },
|
|
11070
|
+
async (request, reply) => {
|
|
11071
|
+
let yaml;
|
|
11072
|
+
try {
|
|
11073
|
+
yaml = await readNodesYaml(resolveNodesYamlPath(deps));
|
|
11074
|
+
} catch (err) {
|
|
11075
|
+
request.log.error(
|
|
11076
|
+
{ err: err instanceof Error ? err.message : String(err) },
|
|
11077
|
+
"earnings: nodes.yaml read/validate failed"
|
|
11078
|
+
);
|
|
11079
|
+
return reply.status(500).send({ error: "nodes_yaml_invalid" });
|
|
11080
|
+
}
|
|
11081
|
+
const peerTypeResolver = new PeerTypeResolver(yaml);
|
|
11082
|
+
const deltaComputer = createDeltaComputer({
|
|
11083
|
+
snapshotPath: resolveSnapshotPath(deps)
|
|
11084
|
+
});
|
|
11085
|
+
return aggregateEarnings({
|
|
11086
|
+
connectorAdmin: deps.connectorAdmin,
|
|
11087
|
+
peerTypeResolver,
|
|
11088
|
+
deltaComputer,
|
|
11089
|
+
logger: request.log
|
|
11090
|
+
});
|
|
11091
|
+
}
|
|
11092
|
+
);
|
|
11093
|
+
}
|
|
11094
|
+
|
|
11095
|
+
// src/api/routes/logs.ts
|
|
11096
|
+
import Docker from "dockerode";
|
|
11097
|
+
|
|
11098
|
+
// src/docker/log-tail.ts
|
|
11099
|
+
var LOG_SERVICES = [
|
|
11100
|
+
"town",
|
|
11101
|
+
"mill",
|
|
11102
|
+
"dvm",
|
|
11103
|
+
"connector"
|
|
11104
|
+
];
|
|
11105
|
+
function stripDockerFrame(chunk) {
|
|
11106
|
+
if (chunk.length < 8) return chunk;
|
|
11107
|
+
const streamType = chunk[0];
|
|
11108
|
+
if (streamType !== 1 && streamType !== 2 || chunk[1] !== 0 || chunk[2] !== 0 || chunk[3] !== 0) {
|
|
11109
|
+
return chunk;
|
|
11110
|
+
}
|
|
11111
|
+
const out = [];
|
|
11112
|
+
let offset = 0;
|
|
11113
|
+
while (offset + 8 <= chunk.length) {
|
|
11114
|
+
const st = chunk[offset];
|
|
11115
|
+
if (st !== 1 && st !== 2 || chunk[offset + 1] !== 0 || chunk[offset + 2] !== 0 || chunk[offset + 3] !== 0) {
|
|
11116
|
+
out.push(chunk.subarray(offset));
|
|
11117
|
+
return Buffer.concat(out);
|
|
11118
|
+
}
|
|
11119
|
+
const size = chunk.readUInt32BE(offset + 4);
|
|
11120
|
+
const start = offset + 8;
|
|
11121
|
+
const end = start + size;
|
|
11122
|
+
if (end > chunk.length) {
|
|
11123
|
+
out.push(chunk.subarray(start));
|
|
11124
|
+
return Buffer.concat(out);
|
|
11125
|
+
}
|
|
11126
|
+
out.push(chunk.subarray(start, end));
|
|
11127
|
+
offset = end;
|
|
11128
|
+
}
|
|
11129
|
+
return Buffer.concat(out);
|
|
11130
|
+
}
|
|
11131
|
+
function parseLogLine(line, service) {
|
|
11132
|
+
const trimmed = line.replace(/\r$/, "").trim();
|
|
11133
|
+
if (!trimmed) return null;
|
|
11134
|
+
if (trimmed.startsWith("{") && trimmed.endsWith("}")) {
|
|
11135
|
+
try {
|
|
11136
|
+
const obj = JSON.parse(trimmed);
|
|
11137
|
+
const level = pinoLevelToLevel(obj["level"]);
|
|
11138
|
+
const ts = pickTimestamp(obj["time"] ?? obj["ts"] ?? obj["@timestamp"]);
|
|
11139
|
+
const msg = pickMsg(obj["msg"] ?? obj["message"] ?? obj["text"]) ?? trimmed;
|
|
11140
|
+
return { ts, service, level, msg, raw: trimmed };
|
|
11141
|
+
} catch {
|
|
11142
|
+
}
|
|
11143
|
+
}
|
|
11144
|
+
const levelMatch = trimmed.match(
|
|
11145
|
+
/^\s*\[?(DEBUG|INFO|WARN|WARNING|ERROR|ERR|FATAL)\]?[:\s]+(.*)$/i
|
|
11146
|
+
);
|
|
11147
|
+
if (levelMatch && levelMatch[1] !== void 0) {
|
|
11148
|
+
const lvl = levelMatch[1].toUpperCase();
|
|
11149
|
+
const msg = levelMatch[2] ?? "";
|
|
11150
|
+
return {
|
|
11151
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
11152
|
+
service,
|
|
11153
|
+
level: textLevelToLevel(lvl),
|
|
11154
|
+
msg,
|
|
11155
|
+
raw: trimmed
|
|
11156
|
+
};
|
|
11157
|
+
}
|
|
11158
|
+
return {
|
|
11159
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
11160
|
+
service,
|
|
11161
|
+
level: "info",
|
|
11162
|
+
msg: trimmed,
|
|
11163
|
+
raw: trimmed
|
|
11164
|
+
};
|
|
11165
|
+
}
|
|
11166
|
+
function pinoLevelToLevel(raw) {
|
|
11167
|
+
if (typeof raw === "number") {
|
|
11168
|
+
if (raw >= 50) return "error";
|
|
11169
|
+
if (raw >= 40) return "warn";
|
|
11170
|
+
if (raw >= 30) return "info";
|
|
11171
|
+
return "debug";
|
|
11172
|
+
}
|
|
11173
|
+
if (typeof raw === "string") {
|
|
11174
|
+
return textLevelToLevel(raw.toUpperCase());
|
|
11175
|
+
}
|
|
11176
|
+
return "info";
|
|
11177
|
+
}
|
|
11178
|
+
function textLevelToLevel(upper) {
|
|
11179
|
+
switch (upper) {
|
|
11180
|
+
case "DEBUG":
|
|
11181
|
+
case "TRACE":
|
|
11182
|
+
return "debug";
|
|
11183
|
+
case "WARN":
|
|
11184
|
+
case "WARNING":
|
|
11185
|
+
return "warn";
|
|
11186
|
+
case "ERR":
|
|
11187
|
+
case "ERROR":
|
|
11188
|
+
case "FATAL":
|
|
11189
|
+
case "CRITICAL":
|
|
11190
|
+
return "error";
|
|
11191
|
+
case "INFO":
|
|
11192
|
+
default:
|
|
11193
|
+
return "info";
|
|
11194
|
+
}
|
|
11195
|
+
}
|
|
11196
|
+
function pickTimestamp(value) {
|
|
11197
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
11198
|
+
return new Date(value).toISOString();
|
|
11199
|
+
}
|
|
11200
|
+
if (typeof value === "string") {
|
|
11201
|
+
const d = new Date(value);
|
|
11202
|
+
if (!Number.isNaN(d.getTime())) return d.toISOString();
|
|
11203
|
+
}
|
|
11204
|
+
return (/* @__PURE__ */ new Date()).toISOString();
|
|
11205
|
+
}
|
|
11206
|
+
function pickMsg(value) {
|
|
11207
|
+
if (typeof value === "string") return value;
|
|
11208
|
+
if (value == null) return null;
|
|
11209
|
+
try {
|
|
11210
|
+
return JSON.stringify(value);
|
|
11211
|
+
} catch {
|
|
11212
|
+
return String(value);
|
|
11213
|
+
}
|
|
11214
|
+
}
|
|
11215
|
+
var LineSplitter = class {
|
|
11216
|
+
buffer = "";
|
|
11217
|
+
push(chunk) {
|
|
11218
|
+
this.buffer += stripDockerFrame(chunk).toString("utf8");
|
|
11219
|
+
const lines = this.buffer.split("\n");
|
|
11220
|
+
this.buffer = lines.pop() ?? "";
|
|
11221
|
+
return lines;
|
|
11222
|
+
}
|
|
11223
|
+
flush() {
|
|
11224
|
+
if (!this.buffer) return [];
|
|
11225
|
+
const out = [this.buffer];
|
|
11226
|
+
this.buffer = "";
|
|
11227
|
+
return out;
|
|
11228
|
+
}
|
|
11229
|
+
};
|
|
11230
|
+
function serviceFromContainerName(name) {
|
|
11231
|
+
const clean = name.replace(/^\//, "");
|
|
11232
|
+
if (!clean.startsWith(CONTAINER_PREFIX)) return null;
|
|
11233
|
+
const suffix = clean.slice(CONTAINER_PREFIX.length);
|
|
11234
|
+
for (const svc of LOG_SERVICES) {
|
|
11235
|
+
if (suffix === svc || suffix.startsWith(`${svc}-`) || suffix.includes(`-${svc}-`) || suffix.endsWith(`-${svc}`)) {
|
|
11236
|
+
return svc;
|
|
11237
|
+
}
|
|
11238
|
+
}
|
|
11239
|
+
return null;
|
|
11240
|
+
}
|
|
11241
|
+
async function* tailContainerLogs(docker, containerName, service, opts = {}) {
|
|
11242
|
+
const tail = opts.tail ?? 50;
|
|
11243
|
+
const container = docker.getContainer(containerName);
|
|
11244
|
+
const stream = await container.logs({
|
|
11245
|
+
follow: true,
|
|
11246
|
+
stdout: true,
|
|
11247
|
+
stderr: true,
|
|
11248
|
+
tail,
|
|
11249
|
+
timestamps: false
|
|
11250
|
+
});
|
|
11251
|
+
const splitter = new LineSplitter();
|
|
11252
|
+
const queue = [];
|
|
11253
|
+
let waiter = null;
|
|
11254
|
+
let done = false;
|
|
11255
|
+
let err = null;
|
|
11256
|
+
function wake() {
|
|
11257
|
+
if (waiter) {
|
|
11258
|
+
const w = waiter;
|
|
11259
|
+
waiter = null;
|
|
11260
|
+
w();
|
|
11261
|
+
}
|
|
11262
|
+
}
|
|
11263
|
+
stream.on("data", (chunk) => {
|
|
11264
|
+
for (const line of splitter.push(chunk)) {
|
|
11265
|
+
const evt = parseLogLine(line, service);
|
|
11266
|
+
if (evt) queue.push(evt);
|
|
11267
|
+
}
|
|
11268
|
+
wake();
|
|
11269
|
+
});
|
|
11270
|
+
stream.on("end", () => {
|
|
11271
|
+
for (const line of splitter.flush()) {
|
|
11272
|
+
const evt = parseLogLine(line, service);
|
|
11273
|
+
if (evt) queue.push(evt);
|
|
11274
|
+
}
|
|
11275
|
+
done = true;
|
|
11276
|
+
wake();
|
|
11277
|
+
});
|
|
11278
|
+
stream.on("error", (e) => {
|
|
11279
|
+
err = e;
|
|
11280
|
+
done = true;
|
|
11281
|
+
wake();
|
|
11282
|
+
});
|
|
11283
|
+
if (opts.signal) {
|
|
11284
|
+
if (opts.signal.aborted) {
|
|
11285
|
+
try {
|
|
11286
|
+
stream.destroy();
|
|
11287
|
+
} catch {
|
|
11288
|
+
}
|
|
11289
|
+
done = true;
|
|
11290
|
+
} else {
|
|
11291
|
+
opts.signal.addEventListener(
|
|
11292
|
+
"abort",
|
|
11293
|
+
() => {
|
|
11294
|
+
try {
|
|
11295
|
+
stream.destroy();
|
|
11296
|
+
} catch {
|
|
11297
|
+
}
|
|
11298
|
+
done = true;
|
|
11299
|
+
wake();
|
|
11300
|
+
},
|
|
11301
|
+
{ once: true }
|
|
11302
|
+
);
|
|
11303
|
+
}
|
|
11304
|
+
}
|
|
11305
|
+
while (true) {
|
|
11306
|
+
const next = queue.shift();
|
|
11307
|
+
if (next !== void 0) {
|
|
11308
|
+
yield next;
|
|
11309
|
+
continue;
|
|
11310
|
+
}
|
|
11311
|
+
if (done) break;
|
|
11312
|
+
await new Promise((resolve2) => {
|
|
11313
|
+
waiter = resolve2;
|
|
11314
|
+
});
|
|
11315
|
+
}
|
|
11316
|
+
if (err) throw err;
|
|
11317
|
+
}
|
|
11318
|
+
|
|
11319
|
+
// src/api/routes/logs.ts
|
|
11320
|
+
var HEARTBEAT_INTERVAL_MS2 = 15e3;
|
|
11321
|
+
async function listTownhouseContainers(docker) {
|
|
11322
|
+
const containers = await docker.listContainers({ all: false });
|
|
11323
|
+
const out = [];
|
|
11324
|
+
for (const c of containers) {
|
|
11325
|
+
for (const rawName of c.Names) {
|
|
11326
|
+
const name = rawName.startsWith("/") ? rawName.slice(1) : rawName;
|
|
11327
|
+
const service = serviceFromContainerName(name);
|
|
11328
|
+
if (service) {
|
|
11329
|
+
out.push({ name, service });
|
|
11330
|
+
break;
|
|
11331
|
+
}
|
|
11332
|
+
}
|
|
11333
|
+
}
|
|
11334
|
+
return out;
|
|
11335
|
+
}
|
|
11336
|
+
function registerLogsRoutes(app, _deps, opts = {}) {
|
|
11337
|
+
const docker = opts.docker ?? new Docker();
|
|
11338
|
+
const tailFn = opts.tailFn ?? tailContainerLogs;
|
|
11339
|
+
app.get("/api/logs/stream", async (request, reply) => {
|
|
11340
|
+
await streamLogs(request, reply, docker, tailFn);
|
|
11341
|
+
});
|
|
11342
|
+
}
|
|
11343
|
+
async function streamLogs(request, reply, docker, tailFn) {
|
|
11344
|
+
const raw = reply.raw;
|
|
11345
|
+
raw.statusCode = 200;
|
|
11346
|
+
raw.setHeader("Content-Type", "text/event-stream");
|
|
11347
|
+
raw.setHeader("Cache-Control", "no-cache, no-transform");
|
|
11348
|
+
raw.setHeader("Connection", "keep-alive");
|
|
11349
|
+
raw.setHeader("X-Accel-Buffering", "no");
|
|
11350
|
+
raw.flushHeaders?.();
|
|
11351
|
+
const controller = new AbortController();
|
|
11352
|
+
const heartbeat = setInterval(() => {
|
|
11353
|
+
if (raw.writableEnded) return;
|
|
11354
|
+
try {
|
|
11355
|
+
raw.write(`: heartbeat ${Date.now()}
|
|
11356
|
+
|
|
11357
|
+
`);
|
|
11358
|
+
} catch {
|
|
11359
|
+
}
|
|
11360
|
+
}, HEARTBEAT_INTERVAL_MS2);
|
|
11361
|
+
function teardown() {
|
|
11362
|
+
clearInterval(heartbeat);
|
|
11363
|
+
controller.abort();
|
|
11364
|
+
}
|
|
11365
|
+
request.raw.on("close", teardown);
|
|
11366
|
+
request.raw.on("error", teardown);
|
|
11367
|
+
let containers;
|
|
11368
|
+
try {
|
|
11369
|
+
containers = await listTownhouseContainers(docker);
|
|
11370
|
+
} catch (err) {
|
|
11371
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
11372
|
+
writeEvent(raw, {
|
|
11373
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
11374
|
+
service: "connector",
|
|
11375
|
+
level: "error",
|
|
11376
|
+
msg: `log-tail: docker unavailable (${msg})`
|
|
11377
|
+
});
|
|
11378
|
+
teardown();
|
|
11379
|
+
raw.end();
|
|
11380
|
+
return;
|
|
11381
|
+
}
|
|
11382
|
+
if (containers.length === 0) {
|
|
11383
|
+
writeEvent(raw, {
|
|
11384
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
11385
|
+
service: "connector",
|
|
11386
|
+
level: "warn",
|
|
11387
|
+
msg: "log-tail: no townhouse containers running"
|
|
11388
|
+
});
|
|
11389
|
+
}
|
|
11390
|
+
const tasks = containers.map(async (c) => {
|
|
11391
|
+
try {
|
|
11392
|
+
for await (const evt of tailFn(docker, c.name, c.service, {
|
|
11393
|
+
signal: controller.signal,
|
|
11394
|
+
tail: 50
|
|
11395
|
+
})) {
|
|
11396
|
+
if (raw.writableEnded) break;
|
|
11397
|
+
writeEvent(raw, evt);
|
|
11398
|
+
}
|
|
11399
|
+
} catch (err) {
|
|
11400
|
+
if (controller.signal.aborted) return;
|
|
11401
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
11402
|
+
writeEvent(raw, {
|
|
11403
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
11404
|
+
service: c.service,
|
|
11405
|
+
level: "error",
|
|
11406
|
+
msg: `log-tail: ${c.name} stream error (${msg})`
|
|
11407
|
+
});
|
|
11408
|
+
}
|
|
11409
|
+
});
|
|
11410
|
+
await Promise.allSettled(tasks);
|
|
11411
|
+
teardown();
|
|
11412
|
+
if (!raw.writableEnded) {
|
|
11413
|
+
raw.end();
|
|
11414
|
+
}
|
|
11415
|
+
}
|
|
11416
|
+
function writeEvent(raw, evt) {
|
|
11417
|
+
if (raw.writableEnded) return;
|
|
11418
|
+
try {
|
|
11419
|
+
raw.write(`data: ${JSON.stringify(evt)}
|
|
11420
|
+
|
|
11421
|
+
`);
|
|
11422
|
+
} catch {
|
|
11423
|
+
}
|
|
11424
|
+
}
|
|
11425
|
+
|
|
8017
11426
|
// src/api/server.ts
|
|
8018
11427
|
async function createApiServer(deps) {
|
|
8019
11428
|
const { config, logger } = deps;
|
|
11429
|
+
const snapshotPath = join8(
|
|
11430
|
+
dirname11(deps.configPath),
|
|
11431
|
+
"earnings-snapshots.jsonl"
|
|
11432
|
+
);
|
|
11433
|
+
const snapshotWriter = new SnapshotWriter({
|
|
11434
|
+
connectorAdmin: deps.connectorAdmin,
|
|
11435
|
+
snapshotPath,
|
|
11436
|
+
logger
|
|
11437
|
+
});
|
|
8020
11438
|
const app = await buildFastifyApp({
|
|
8021
11439
|
logger: logger ?? true,
|
|
8022
11440
|
bindHost: config.api.host ?? "127.0.0.1"
|
|
@@ -8036,9 +11454,17 @@ async function createApiServer(deps) {
|
|
|
8036
11454
|
registerWalletRevealRoutes(app, deps);
|
|
8037
11455
|
registerWalletWithdrawRoutes(app, deps);
|
|
8038
11456
|
registerConfigPatchRoutes(app, deps);
|
|
11457
|
+
registerNodeLifecycleRoutes(app, deps);
|
|
11458
|
+
registerEarningsRoutes(app, deps);
|
|
11459
|
+
registerLogsRoutes(app, deps);
|
|
8039
11460
|
registerMetricsWsRoutes(app, deps);
|
|
11461
|
+
snapshotWriter.start();
|
|
8040
11462
|
const CLOSE_TIMEOUT_MS2 = 5e3;
|
|
8041
11463
|
async function close() {
|
|
11464
|
+
try {
|
|
11465
|
+
snapshotWriter.stop();
|
|
11466
|
+
} catch {
|
|
11467
|
+
}
|
|
8042
11468
|
try {
|
|
8043
11469
|
deps.transportProbe.stop();
|
|
8044
11470
|
} catch {
|
|
@@ -8055,7 +11481,7 @@ async function createApiServer(deps) {
|
|
|
8055
11481
|
openSockets.clear();
|
|
8056
11482
|
await Promise.race([
|
|
8057
11483
|
app.close(),
|
|
8058
|
-
new Promise((
|
|
11484
|
+
new Promise((resolve2) => setTimeout(resolve2, CLOSE_TIMEOUT_MS2))
|
|
8059
11485
|
]);
|
|
8060
11486
|
}
|
|
8061
11487
|
return { app, close };
|
|
@@ -8246,7 +11672,7 @@ async function createWizardApiServer(initialDeps) {
|
|
|
8246
11672
|
state.progressSockets.clear();
|
|
8247
11673
|
await Promise.race([
|
|
8248
11674
|
app.close(),
|
|
8249
|
-
new Promise((
|
|
11675
|
+
new Promise((resolve2) => setTimeout(resolve2, CLOSE_TIMEOUT_MS))
|
|
8250
11676
|
]);
|
|
8251
11677
|
}
|
|
8252
11678
|
return { app, close };
|
|
@@ -8260,15 +11686,38 @@ export {
|
|
|
8260
11686
|
saveConfig,
|
|
8261
11687
|
DEFAULT_ATOR_PROXY,
|
|
8262
11688
|
ConnectorConfigGenerator,
|
|
8263
|
-
DockerOrchestrator,
|
|
8264
11689
|
ConnectorAdminClient,
|
|
11690
|
+
OrchestratorError,
|
|
11691
|
+
DockerOrchestrator,
|
|
8265
11692
|
TransportProbe,
|
|
11693
|
+
writeHsConnectorConfig,
|
|
11694
|
+
ComposeLoaderError,
|
|
11695
|
+
loadComposeTemplate,
|
|
11696
|
+
materializeComposeTemplate,
|
|
11697
|
+
NodesYamlEntrySchema,
|
|
11698
|
+
NodesYamlSchema,
|
|
11699
|
+
readNodesYaml,
|
|
11700
|
+
writeNodesYaml,
|
|
11701
|
+
BootReconciler,
|
|
11702
|
+
SnapshotWriter,
|
|
8266
11703
|
saveWallet,
|
|
8267
11704
|
loadWallet,
|
|
8268
11705
|
encryptWallet,
|
|
8269
11706
|
decryptWallet,
|
|
11707
|
+
ImageManifestSchema,
|
|
11708
|
+
isSyntheticDigest,
|
|
11709
|
+
readImageManifest,
|
|
8270
11710
|
WalletManager,
|
|
11711
|
+
aggregateEarnings,
|
|
11712
|
+
utcDayBoundary,
|
|
11713
|
+
utcMonthBoundary,
|
|
11714
|
+
utcYearBoundary,
|
|
11715
|
+
createDeltaComputer,
|
|
11716
|
+
PeerTypeResolver,
|
|
11717
|
+
LOG_SERVICES,
|
|
11718
|
+
serviceFromContainerName,
|
|
11719
|
+
tailContainerLogs,
|
|
8271
11720
|
createApiServer,
|
|
8272
11721
|
createWizardApiServer
|
|
8273
11722
|
};
|
|
8274
|
-
//# sourceMappingURL=chunk-
|
|
11723
|
+
//# sourceMappingURL=chunk-4WCMVIO4.js.map
|