@leofcoin/launch-chain 0.4.20 → 0.4.21

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 CHANGED
@@ -17,6 +17,34 @@ const { chain, endpoints, mode } = await launch(
17
17
  // when mode is direct means chain is directly available and no endpoint is needed to interact with it
18
18
  ```
19
19
 
20
+ ## run a validator
21
+
22
+ No monorepo checkout is required. Run the validator directly from the published package:
23
+
24
+ ```sh
25
+ npx --package @leofcoin/launch-chain leofcoin-validator
26
+ ```
27
+
28
+ The first run uses the normal interactive identity-password prompt. A new account must receive
29
+ the validator contract's minimum balance before it can register. An existing validator must be
30
+ online to finalize that registration transaction.
31
+
32
+ For an unattended server, keep the password outside the repository in a mode-`0600` file:
33
+
34
+ ```sh
35
+ npx --package @leofcoin/launch-chain leofcoin-validator \
36
+ --password-file /secure/leofcoin/password \
37
+ --root /var/lib/leofcoin/peach \
38
+ --interval 5
39
+ ```
40
+
41
+ The validator also exposes the HTTP API on port `8080` and WebSocket API on port `4040`, so one
42
+ process can replace a separate launch-chain server. Change them with `--http-port` and `--ws-port`,
43
+ or use `--no-endpoints` for a validator that should not expose an API.
44
+
45
+ Never put the password or an identity backup in a command-line argument, service file, or Git.
46
+ Use `leofcoin-validator --help` for all options.
47
+
20
48
  ## options
21
49
 
22
50
  ### default
@@ -26,6 +26,7 @@ type launchOptions = {
26
26
  stars?: string[];
27
27
  forceRemote?: boolean;
28
28
  mode?: launchMode;
29
+ root?: string;
29
30
  ws?: endpointOptions[] | undefined;
30
31
  http?: endpointOptions[] | undefined;
31
32
  };
@@ -34,5 +35,5 @@ type launchOptions = {
34
35
  * @param {object} options { ws: boolean || {url: string, port: number}, http: boolean || {url: string, port: number}, network}
35
36
  * @returns '{ mode: string, endpoints: object, chain}'
36
37
  */
37
- declare const launch: (input: launchOptions | undefined, password: string) => Promise<launchReturn>;
38
+ declare const launch: (input?: launchOptions, password?: string) => Promise<launchReturn>;
38
39
  export { launch as default };
package/exports/index.js CHANGED
@@ -11,6 +11,7 @@ const defaultOptions = {
11
11
  stars: networks.leofcoin.peach.stars,
12
12
  forceRemote: false,
13
13
  mode: 'direct',
14
+ root: undefined,
14
15
  ws: [
15
16
  {
16
17
  port: 4040
@@ -108,7 +109,7 @@ const launch = async (input = {}, password) => {
108
109
  throw new Error(`no remotes connected`);
109
110
  }
110
111
  else if (options.mode === 'direct') {
111
- const node = new Node({ network: options.network, stars: options.stars, networkVersion: options.networkVersion }, password);
112
+ const node = new Node({ network: options.network, stars: options.stars, networkVersion: options.networkVersion, root: options.root }, password);
112
113
  await node.ready;
113
114
  await nodeConfig({ network: options.network, networkVersion: options.networkVersion });
114
115
  chain = new Chain({ network: options.network, stars: options.stars, networkVersion: options.networkVersion });
@@ -137,7 +138,7 @@ const launch = async (input = {}, password) => {
137
138
  }
138
139
  }
139
140
  else {
140
- const node = new Node({ network: options.network, stars: options.stars, networkVersion: options.networkVersion }, password);
141
+ const node = new Node({ network: options.network, stars: options.stars, networkVersion: options.networkVersion, root: options.root }, password);
141
142
  await node.ready;
142
143
  await nodeConfig({ network: options.network, networkVersion: options.networkVersion });
143
144
  chain = new Chain({ network: options.network, stars: options.stars, networkVersion: options.networkVersion });
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,72 @@
1
+ #!/usr/bin/env node
2
+ import { readFile } from 'node:fs/promises';
3
+ import { resolve } from 'node:path';
4
+ import { startValidator } from './validator.js';
5
+ import '@leofcoin/addresses';
6
+ import '@leofcoin/lib';
7
+ import '@leofcoin/networks';
8
+ import './index.js';
9
+ import '@leofcoin/chain/node';
10
+ import '@leofcoin/chain/chain';
11
+ import '@leofcoin/lib/node-config';
12
+ import '@leofcoin/endpoint-clients/ws';
13
+ import '@leofcoin/endpoint-clients/http';
14
+
15
+ const args = process.argv.slice(2);
16
+ const valueAfter = (name) => {
17
+ const index = args.indexOf(name);
18
+ if (index === -1)
19
+ return undefined;
20
+ const value = args[index + 1];
21
+ if (value === undefined || value.startsWith('--'))
22
+ throw new Error(`${name} requires a value`);
23
+ return value;
24
+ };
25
+ if (args.includes('--help') || args.includes('-h')) {
26
+ process.stdout.write(`Usage: leofcoin-validator [options]
27
+
28
+ Options:
29
+ --network <name> Network (default: leofcoin:peach)
30
+ --root <path> Persistent Leofcoin data directory
31
+ --password-file <path> Read the identity password from a private file
32
+ --interval <minutes> Heartbeat interval (default: 5, minimum: 1)
33
+ --http-port <port> HTTP API port (default: 8080)
34
+ --ws-port <port> WebSocket API port (default: 4040)
35
+ --no-endpoints Disable both API endpoints
36
+ -h, --help Show this help
37
+
38
+ Without --password-file, LEOFCOIN_PASSWORD is used when set. Otherwise the
39
+ standard interactive password prompt is shown. Never pass a password directly
40
+ as a command-line argument.
41
+ `);
42
+ process.exit(0);
43
+ }
44
+ const passwordFile = valueAfter('--password-file') ?? process.env.LEOFCOIN_PASSWORD_FILE;
45
+ const password = passwordFile
46
+ ? (await readFile(resolve(passwordFile), 'utf8')).trim()
47
+ : process.env.LEOFCOIN_PASSWORD;
48
+ if (passwordFile && !password)
49
+ throw new Error(`empty validator password file: ${resolve(passwordFile)}`);
50
+ const intervalValue = valueAfter('--interval') ?? process.env.LEOFCOIN_TRANSACTION_INTERVAL_MINUTES;
51
+ const noEndpoints = args.includes('--no-endpoints');
52
+ const port = (name, fallback) => {
53
+ const value = Number(valueAfter(name) ?? fallback);
54
+ if (!Number.isSafeInteger(value) || value < 1 || value > 65_535)
55
+ throw new Error(`${name} must be a valid port`);
56
+ return value;
57
+ };
58
+ const handle = await startValidator({
59
+ network: valueAfter('--network') ?? process.env.LEOFCOIN_NETWORK,
60
+ root: valueAfter('--root') ?? process.env.LEOFCOIN_DATA_ROOT,
61
+ password,
62
+ intervalMinutes: intervalValue === undefined ? undefined : Number(intervalValue),
63
+ httpPort: noEndpoints ? false : port('--http-port', 8080),
64
+ wsPort: noEndpoints ? false : port('--ws-port', 4040)
65
+ });
66
+ const shutdown = (signal) => {
67
+ process.stdout.write(`received ${signal}; stopping validator\n`);
68
+ handle.stop();
69
+ setTimeout(() => process.exit(0), 100);
70
+ };
71
+ process.once('SIGINT', () => shutdown('SIGINT'));
72
+ process.once('SIGTERM', () => shutdown('SIGTERM'));
@@ -0,0 +1,21 @@
1
+ import Chain from '@leofcoin/chain/chain';
2
+ export type ValidatorOptions = {
3
+ network?: string;
4
+ networkVersion?: string;
5
+ stars?: string[];
6
+ root?: string;
7
+ password?: string;
8
+ intervalMinutes?: number;
9
+ registrationTimeoutMs?: number;
10
+ httpPort?: number | false;
11
+ wsPort?: number | false;
12
+ signal?: AbortSignal;
13
+ logger?: Pick<Console, 'log' | 'warn' | 'error'>;
14
+ };
15
+ export type ValidatorHandle = {
16
+ account: string;
17
+ chain: Chain;
18
+ stop: () => void;
19
+ };
20
+ export declare const validateIntervalMinutes: (value: number) => number;
21
+ export declare const startValidator: (options?: ValidatorOptions) => Promise<ValidatorHandle>;
@@ -0,0 +1,123 @@
1
+ import addresses from '@leofcoin/addresses';
2
+ import { signTransaction } from '@leofcoin/lib';
3
+ import networks from '@leofcoin/networks';
4
+ import launch from './index.js';
5
+ import '@leofcoin/chain/node';
6
+ import '@leofcoin/chain/chain';
7
+ import '@leofcoin/lib/node-config';
8
+ import '@leofcoin/endpoint-clients/ws';
9
+ import '@leofcoin/endpoint-clients/http';
10
+
11
+ const delay = (milliseconds, signal) => new Promise((resolve, reject) => {
12
+ if (signal?.aborted)
13
+ return reject(signal.reason ?? new Error('validator stopped'));
14
+ const onAbort = () => {
15
+ clearTimeout(timeout);
16
+ reject(signal?.reason ?? new Error('validator stopped'));
17
+ };
18
+ const timeout = setTimeout(() => {
19
+ signal?.removeEventListener('abort', onAbort);
20
+ resolve();
21
+ }, milliseconds);
22
+ signal?.addEventListener('abort', onAbort, { once: true });
23
+ });
24
+ const validateIntervalMinutes = (value) => {
25
+ if (!Number.isFinite(value) || value < 1)
26
+ throw new Error('validator interval must be at least one minute');
27
+ return value;
28
+ };
29
+ const startValidator = async (options = {}) => {
30
+ const network = options.network ?? 'leofcoin:peach';
31
+ const networkVersion = options.networkVersion ?? network.split(':')[1] ?? 'peach';
32
+ const stars = options.stars ?? networks.leofcoin.peach.stars;
33
+ const intervalMinutes = validateIntervalMinutes(options.intervalMinutes ?? 5);
34
+ const registrationTimeoutMs = options.registrationTimeoutMs ?? 5 * 60_000;
35
+ const logger = options.logger ?? console;
36
+ const controller = new AbortController();
37
+ options.signal?.addEventListener('abort', () => controller.abort(options.signal?.reason), { once: true });
38
+ const launched = await launch({
39
+ mode: 'direct',
40
+ network,
41
+ networkVersion,
42
+ stars,
43
+ root: options.root,
44
+ http: options.httpPort === false ? [] : [{ port: options.httpPort ?? 8080 }],
45
+ ws: options.wsPort === false ? [] : [{ port: options.wsPort ?? 4040 }]
46
+ }, options.password);
47
+ if (!launched.chain)
48
+ throw new Error('validator launch did not create a direct chain runtime');
49
+ const chain = launched.chain;
50
+ const chainRuntime = chain;
51
+ const peernet = globalThis.peernet;
52
+ const account = peernet?.selectedAccount;
53
+ if (!account)
54
+ throw new Error('validator identity has no selected account');
55
+ logger.log(`validator account: ${account}`);
56
+ const isRegistered = () => chainRuntime.staticCall(addresses.validators, 'has', [account]);
57
+ if (!(await isRegistered())) {
58
+ const [balanceValue, minimumValue] = await Promise.all([
59
+ chainRuntime.balanceOf(account),
60
+ chainRuntime.staticCall(addresses.validators, 'minimumBalance')
61
+ ]);
62
+ const balance = BigInt(balanceValue);
63
+ const minimumBalance = BigInt(minimumValue);
64
+ if (balance < minimumBalance) {
65
+ throw new Error(`validator account ${account} has balance ${balance}; registration requires at least ${minimumBalance}`);
66
+ }
67
+ const submitted = await chainRuntime.participate(account);
68
+ if (submitted)
69
+ throw new Error('validator registration state changed unexpectedly');
70
+ logger.log('validator registration submitted; waiting for finalization...');
71
+ const deadline = Date.now() + registrationTimeoutMs;
72
+ while (Date.now() < deadline && !(await isRegistered()))
73
+ await delay(5_000, controller.signal);
74
+ if (!(await isRegistered())) {
75
+ throw new Error('validator registration was not finalized; at least one existing validator must be online to produce it');
76
+ }
77
+ }
78
+ if (!(await chainRuntime.participate(account))) {
79
+ throw new Error('validator is registered but participation did not activate');
80
+ }
81
+ logger.log('validator participation active');
82
+ let sending = false;
83
+ const heartbeat = async () => {
84
+ if (controller.signal.aborted || sending)
85
+ return;
86
+ sending = true;
87
+ try {
88
+ const balance = BigInt(await chainRuntime.balanceOf(account));
89
+ if (balance <= 1n) {
90
+ logger.warn(`heartbeat skipped: balance ${balance} is too low`);
91
+ return;
92
+ }
93
+ const raw = await chainRuntime.createTransaction({
94
+ from: account,
95
+ to: chain.nativeToken,
96
+ method: 'transfer',
97
+ params: [account, account, 1n]
98
+ });
99
+ const signed = await signTransaction(raw, peernet.identity);
100
+ const pending = await chainRuntime.sendTransaction(signed);
101
+ const hash = await pending.wait;
102
+ logger.log(`heartbeat transaction finalized: ${hash}`);
103
+ }
104
+ catch (error) {
105
+ logger.error(`heartbeat transaction failed: ${error?.message ?? error}`);
106
+ }
107
+ finally {
108
+ sending = false;
109
+ }
110
+ };
111
+ await heartbeat();
112
+ const interval = setInterval(heartbeat, intervalMinutes * 60_000);
113
+ const stop = () => {
114
+ if (controller.signal.aborted)
115
+ return;
116
+ controller.abort(new Error('validator stopped'));
117
+ clearInterval(interval);
118
+ };
119
+ controller.signal.addEventListener('abort', () => clearInterval(interval), { once: true });
120
+ return { account, chain, stop };
121
+ };
122
+
123
+ export { startValidator, validateIntervalMinutes };
package/package.json CHANGED
@@ -1,13 +1,20 @@
1
1
  {
2
2
  "name": "@leofcoin/launch-chain",
3
- "version": "0.4.20",
3
+ "version": "0.4.21",
4
4
  "description": "",
5
5
  "exports": {
6
6
  ".": {
7
7
  "import": "./exports/index.js",
8
8
  "types": "./exports/index.d.ts"
9
+ },
10
+ "./validator": {
11
+ "import": "./exports/validator.js",
12
+ "types": "./exports/validator.d.ts"
9
13
  }
10
14
  },
15
+ "bin": {
16
+ "leofcoin-validator": "./exports/validator-cli.js"
17
+ },
11
18
  "files": [
12
19
  "exports"
13
20
  ],
@@ -29,9 +36,11 @@
29
36
  },
30
37
  "homepage": "https://github.com/leofcoin/monorepo/tree/main/packages/launch-chain#readme",
31
38
  "dependencies": {
32
- "@leofcoin/chain": "^1.10.11",
33
- "@leofcoin/endpoint-clients": "^0.3.15",
34
- "@leofcoin/endpoints": "^0.4.8",
39
+ "@leofcoin/addresses": "^1.0.62",
40
+ "@leofcoin/chain": "^1.10.12",
41
+ "@leofcoin/endpoint-clients": "^0.3.16",
42
+ "@leofcoin/endpoints": "^0.4.9",
43
+ "@leofcoin/lib": "^1.3.10",
35
44
  "@leofcoin/networks": "^1.1.30"
36
45
  },
37
46
  "devDependencies": {