ovtools 0.0.0-stage → 1.0.1

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.
Files changed (198) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +121 -2
  3. package/assets/common/bond-inspect.sh +368 -0
  4. package/assets/common/config.sh +263 -0
  5. package/assets/common/extract-data.sh +197 -0
  6. package/assets/common/harden-host.sh +37 -0
  7. package/assets/common/install-deps.sh +93 -0
  8. package/assets/common/node-enroll.sh +65 -0
  9. package/assets/common/node-setup.sh +150 -0
  10. package/assets/common/node-start.sh +90 -0
  11. package/assets/common/node-stop.sh +18 -0
  12. package/assets/common/node-upgrade.sh +45 -0
  13. package/assets/common/pre-run.sh +497 -0
  14. package/assets/common/promote-validator.sh +104 -0
  15. package/assets/deploy/10-prepare-host.sh +98 -0
  16. package/assets/deploy/20-install-toolchain.sh +93 -0
  17. package/assets/deploy/30-setup-node.sh +82 -0
  18. package/assets/deploy/40-start-node.sh +75 -0
  19. package/assets/deploy/50-verify.sh +259 -0
  20. package/assets/deploy/60-enroll.sh +304 -0
  21. package/assets/deploy/70-promote.sh +152 -0
  22. package/assets/deploy/lib.sh +398 -0
  23. package/assets/manage/10-bond-increase.sh +149 -0
  24. package/assets/manage/10-diagnose-node.sh +169 -0
  25. package/assets/manage/20-exit-validator.sh +259 -0
  26. package/assets/manage/25-recover-node.sh +291 -0
  27. package/assets/manage/30-status.sh +64 -0
  28. package/assets/manage/40-exit-withdraw.sh +133 -0
  29. package/assets/manage/bond_inc.sh +318 -0
  30. package/assets/manage/dashboard_monitor/app.js +1544 -0
  31. package/assets/manage/dashboard_monitor/assets/v2.2/css/base.css +113 -0
  32. package/assets/manage/dashboard_monitor/assets/v2.2/css/components/content.css +133 -0
  33. package/assets/manage/dashboard_monitor/assets/v2.2/css/components/controls.css +229 -0
  34. package/assets/manage/dashboard_monitor/assets/v2.2/css/components/data.css +374 -0
  35. package/assets/manage/dashboard_monitor/assets/v2.2/css/components/icons.css +35 -0
  36. package/assets/manage/dashboard_monitor/assets/v2.2/css/components/motion.css +57 -0
  37. package/assets/manage/dashboard_monitor/assets/v2.2/css/components/shell.css +51 -0
  38. package/assets/manage/dashboard_monitor/assets/v2.2/css/components/sidebar.css +242 -0
  39. package/assets/manage/dashboard_monitor/assets/v2.2/css/components/topbar.css +138 -0
  40. package/assets/manage/dashboard_monitor/assets/v2.2/css/fonts.css +30 -0
  41. package/assets/manage/dashboard_monitor/assets/v2.2/css/synteq-ui-v2.css +25 -0
  42. package/assets/manage/dashboard_monitor/assets/v2.2/css/theme-dark.css +38 -0
  43. package/assets/manage/dashboard_monitor/assets/v2.2/css/theme-light.css +36 -0
  44. package/assets/manage/dashboard_monitor/assets/v2.2/css/tokens.css +61 -0
  45. package/assets/manage/dashboard_monitor/assets/v2.2/fonts/GoogleSansCode-Medium.ttf +0 -0
  46. package/assets/manage/dashboard_monitor/assets/v2.2/fonts/GoogleSansCode-Regular.ttf +0 -0
  47. package/assets/manage/dashboard_monitor/assets/v2.2/fonts/GoogleSansCode-SemiBold.ttf +0 -0
  48. package/assets/manage/dashboard_monitor/assets/v2.2/js/synteq-ui-v2.js +869 -0
  49. package/assets/manage/dashboard_monitor/deploy-dashboard.sh +230 -0
  50. package/assets/manage/dashboard_monitor/favicon.svg +13 -0
  51. package/assets/manage/dashboard_monitor/index.html +1197 -0
  52. package/assets/manage/dashboard_monitor/nginx-octra.conf +76 -0
  53. package/assets/manage/dashboard_monitor/octra-dashboard.service +29 -0
  54. package/assets/manage/dashboard_monitor/octra-node-prune.cron +12 -0
  55. package/assets/manage/dashboard_monitor/octra-node-prune.service +16 -0
  56. package/assets/manage/dashboard_monitor/octra-node-prune.timer +12 -0
  57. package/assets/manage/dashboard_monitor/prune-node-junk.sh +329 -0
  58. package/assets/manage/dashboard_monitor/server.py +1887 -0
  59. package/assets/manage/dashboard_monitor/ssl-certbot.sh +90 -0
  60. package/assets/manage/dashboard_monitor/styles.css +327 -0
  61. package/assets/manage/lib.sh +347 -0
  62. package/assets/manifest.json +143 -0
  63. package/assets/migrate/00-backup-identity.sh +67 -0
  64. package/assets/migrate/01-prepare-target.sh +145 -0
  65. package/assets/migrate/02-authorize-pull.sh +43 -0
  66. package/assets/migrate/03-pull-state.sh +102 -0
  67. package/assets/migrate/04-stop-source.sh +85 -0
  68. package/assets/migrate/05-finalize-target.sh +140 -0
  69. package/assets/migrate/06-start-target.sh +57 -0
  70. package/assets/migrate/07-verify.sh +237 -0
  71. package/assets/migrate/08-rollback.sh +54 -0
  72. package/assets/migrate/lib.sh +149 -0
  73. package/assets/upgrade/10-preflight.sh +120 -0
  74. package/assets/upgrade/20-upgrade.sh +142 -0
  75. package/assets/upgrade/30-manual-upgrade.sh +129 -0
  76. package/assets/upgrade/40-storage-prune.sh +65 -0
  77. package/assets/upgrade/50-status.sh +58 -0
  78. package/assets/upgrade/lib.sh +232 -0
  79. package/bin/ovtools.js +32 -0
  80. package/dist/api/checks.d.ts +6 -0
  81. package/dist/api/checks.d.ts.map +1 -0
  82. package/dist/api/checks.js +173 -0
  83. package/dist/api/checks.js.map +1 -0
  84. package/dist/api/hostkeys.d.ts +10 -0
  85. package/dist/api/hostkeys.d.ts.map +1 -0
  86. package/dist/api/hostkeys.js +58 -0
  87. package/dist/api/hostkeys.js.map +1 -0
  88. package/dist/api/jobs.d.ts +33 -0
  89. package/dist/api/jobs.d.ts.map +1 -0
  90. package/dist/api/jobs.js +486 -0
  91. package/dist/api/jobs.js.map +1 -0
  92. package/dist/api/nodes.d.ts +16 -0
  93. package/dist/api/nodes.d.ts.map +1 -0
  94. package/dist/api/nodes.js +126 -0
  95. package/dist/api/nodes.js.map +1 -0
  96. package/dist/api/server.d.ts +3 -0
  97. package/dist/api/server.d.ts.map +1 -0
  98. package/dist/api/server.js +443 -0
  99. package/dist/api/server.js.map +1 -0
  100. package/dist/cli.d.ts +3 -0
  101. package/dist/cli.d.ts.map +1 -0
  102. package/dist/cli.js +301 -0
  103. package/dist/cli.js.map +1 -0
  104. package/dist/constants.d.ts +76 -0
  105. package/dist/constants.d.ts.map +1 -0
  106. package/dist/constants.js +150 -0
  107. package/dist/constants.js.map +1 -0
  108. package/dist/index.d.ts +18 -0
  109. package/dist/index.d.ts.map +1 -0
  110. package/dist/index.js +60 -0
  111. package/dist/index.js.map +1 -0
  112. package/dist/orchestrator/base.d.ts +52 -0
  113. package/dist/orchestrator/base.d.ts.map +1 -0
  114. package/dist/orchestrator/base.js +219 -0
  115. package/dist/orchestrator/base.js.map +1 -0
  116. package/dist/orchestrator/deploy.d.ts +33 -0
  117. package/dist/orchestrator/deploy.d.ts.map +1 -0
  118. package/dist/orchestrator/deploy.js +313 -0
  119. package/dist/orchestrator/deploy.js.map +1 -0
  120. package/dist/orchestrator/manage.d.ts +28 -0
  121. package/dist/orchestrator/manage.d.ts.map +1 -0
  122. package/dist/orchestrator/manage.js +267 -0
  123. package/dist/orchestrator/manage.js.map +1 -0
  124. package/dist/orchestrator/migrate.d.ts +31 -0
  125. package/dist/orchestrator/migrate.d.ts.map +1 -0
  126. package/dist/orchestrator/migrate.js +442 -0
  127. package/dist/orchestrator/migrate.js.map +1 -0
  128. package/dist/ssh/client.d.ts +39 -0
  129. package/dist/ssh/client.d.ts.map +1 -0
  130. package/dist/ssh/client.js +418 -0
  131. package/dist/ssh/client.js.map +1 -0
  132. package/dist/test/core.test.d.ts +2 -0
  133. package/dist/test/core.test.d.ts.map +1 -0
  134. package/dist/test/core.test.js +188 -0
  135. package/dist/test/core.test.js.map +1 -0
  136. package/dist/types.d.ts +267 -0
  137. package/dist/types.d.ts.map +1 -0
  138. package/dist/types.js +3 -0
  139. package/dist/types.js.map +1 -0
  140. package/dist/utils/assets.d.ts +28 -0
  141. package/dist/utils/assets.d.ts.map +1 -0
  142. package/dist/utils/assets.js +583 -0
  143. package/dist/utils/assets.js.map +1 -0
  144. package/dist/utils/browser.d.ts +2 -0
  145. package/dist/utils/browser.d.ts.map +1 -0
  146. package/dist/utils/browser.js +23 -0
  147. package/dist/utils/browser.js.map +1 -0
  148. package/dist/utils/dashboard.d.ts +16 -0
  149. package/dist/utils/dashboard.d.ts.map +1 -0
  150. package/dist/utils/dashboard.js +177 -0
  151. package/dist/utils/dashboard.js.map +1 -0
  152. package/dist/utils/helpers.d.ts +29 -0
  153. package/dist/utils/helpers.d.ts.map +1 -0
  154. package/dist/utils/helpers.js +204 -0
  155. package/dist/utils/helpers.js.map +1 -0
  156. package/dist/utils/scripts.d.ts +60 -0
  157. package/dist/utils/scripts.d.ts.map +1 -0
  158. package/dist/utils/scripts.js +174 -0
  159. package/dist/utils/scripts.js.map +1 -0
  160. package/dist/utils/state.d.ts +12 -0
  161. package/dist/utils/state.d.ts.map +1 -0
  162. package/dist/utils/state.js +44 -0
  163. package/dist/utils/state.js.map +1 -0
  164. package/dist/utils/tar.d.ts +11 -0
  165. package/dist/utils/tar.d.ts.map +1 -0
  166. package/dist/utils/tar.js +93 -0
  167. package/dist/utils/tar.js.map +1 -0
  168. package/dist/utils/update.d.ts +9 -0
  169. package/dist/utils/update.d.ts.map +1 -0
  170. package/dist/utils/update.js +134 -0
  171. package/dist/utils/update.js.map +1 -0
  172. package/dist/utils/websocket.d.ts +10 -0
  173. package/dist/utils/websocket.d.ts.map +1 -0
  174. package/dist/utils/websocket.js +203 -0
  175. package/dist/utils/websocket.js.map +1 -0
  176. package/package.json +55 -4
  177. package/public/app.js +2736 -0
  178. package/public/assets/v2.2/css/base.css +113 -0
  179. package/public/assets/v2.2/css/components/content.css +133 -0
  180. package/public/assets/v2.2/css/components/controls.css +229 -0
  181. package/public/assets/v2.2/css/components/data.css +373 -0
  182. package/public/assets/v2.2/css/components/icons.css +35 -0
  183. package/public/assets/v2.2/css/components/motion.css +57 -0
  184. package/public/assets/v2.2/css/components/shell.css +51 -0
  185. package/public/assets/v2.2/css/components/sidebar.css +242 -0
  186. package/public/assets/v2.2/css/components/topbar.css +138 -0
  187. package/public/assets/v2.2/css/fonts.css +30 -0
  188. package/public/assets/v2.2/css/synteq-ui-v2.css +25 -0
  189. package/public/assets/v2.2/css/theme-dark.css +38 -0
  190. package/public/assets/v2.2/css/theme-light.css +36 -0
  191. package/public/assets/v2.2/css/tokens.css +61 -0
  192. package/public/assets/v2.2/fonts/GoogleSansCode-Medium.ttf +0 -0
  193. package/public/assets/v2.2/fonts/GoogleSansCode-Regular.ttf +0 -0
  194. package/public/assets/v2.2/fonts/GoogleSansCode-SemiBold.ttf +0 -0
  195. package/public/assets/v2.2/js/synteq-ui-v2.js +869 -0
  196. package/public/favicon.svg +13 -0
  197. package/public/index.html +1083 -0
  198. package/public/styles.css +580 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ovtools contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,122 @@
1
- # Temporary Holding Version
1
+ # ovtools
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Deploy, migrate and manage Octra validators from one Node.js CLI and web console.
4
+
5
+ ## Features
6
+
7
+ - **Deploy** a fresh VPS as an observer or validator.
8
+ - **Migrate** a running validator to a new VPS with no manual copying.
9
+ - **Manage** an active node: bond, exit, update, diagnose, recover, prune, and
10
+ deploy the read-only monitoring dashboard.
11
+ - **Web console** with live logs, node status, precondition checks and guided
12
+ enrollment/activation/exit steps.
13
+ - **Resumable** operations: long steps keep running on the VPS and a retry
14
+ continues where the last one stopped.
15
+
16
+ ## Requirements
17
+
18
+ - Node.js 18 or newer on your computer (Windows, Linux or macOS).
19
+ - A Debian/Ubuntu VPS with root SSH access.
20
+
21
+ No extra SSH client or runtime is required on your computer.
22
+
23
+ ## Install
24
+
25
+ ```bash
26
+ npm install -g ovtools
27
+ ovtools # start the console and open the browser
28
+ ovtools --version
29
+ ovtools help
30
+ ```
31
+
32
+ Running `ovtools` with no arguments starts the web console on
33
+ `http://127.0.0.1:9091` and opens your browser. It first checks npm for a newer
34
+ release: when one exists it prints the update command and exits; otherwise the
35
+ console starts.
36
+
37
+ ## Quick start
38
+
39
+ ```bash
40
+ # Deploy a fresh validator
41
+ ovtools deploy --ip <vps-ip> --password '<root-password>' --bond-oct 1
42
+
43
+ # Deploy an observer
44
+ ovtools deploy --ip <vps-ip> --key ~/.ssh/id_ed25519 --role observer
45
+
46
+ # Migrate an existing validator
47
+ ovtools migrate --old-ip <old-ip> --new-ip <new-ip> \
48
+ --old-password '<old-password>' --new-password '<new-password>'
49
+
50
+ # Manage a running node
51
+ ovtools manage --ip <vps-ip> --password '<root-password>' --phase status
52
+ ovtools manage --ip <vps-ip> --password '<root-password>' --phase bond --amount 100
53
+ ovtools manage --ip <vps-ip> --password '<root-password>' --phase dashboard
54
+ ovtools upgrade --ip <vps-ip> --password '<root-password>' --phase upgrade
55
+ ```
56
+
57
+ Run `ovtools help` for all options and phases.
58
+
59
+ ## Web console
60
+
61
+ ```bash
62
+ ovtools web # start and open the browser
63
+ ovtools web --no-open # start without opening a browser
64
+ # http://127.0.0.1:9091
65
+ ```
66
+
67
+ The console listens on localhost by default. If port 9091 is busy it picks the
68
+ next free port. To require an API token:
69
+
70
+ ```bash
71
+ OVTOOLS_TOKEN='<secret>' ovtools web
72
+ ```
73
+
74
+ ## Where data lives
75
+
76
+ `~/.ovtools` holds run logs, resumable step state, downloaded identity backups,
77
+ pasted keys/certificates and pinned SSH host keys.
78
+
79
+ Passwords are kept in memory only and are never written to disk.
80
+
81
+ ## Assets on the VPS
82
+
83
+ The console copies its deployment scripts to the VPS automatically. By default it
84
+ clones this repository (`https://github.com/m-tq/ovtools.git`) on the VPS and uses
85
+ only the task it needs. If cloning is not possible, it falls back to a local
86
+ transfer automatically.
87
+
88
+ To use a fork:
89
+
90
+ ```bash
91
+ ovtools config --assets-repo https://github.com/<you>/ovtools.git
92
+ ovtools config
93
+ ```
94
+
95
+ ## Development
96
+
97
+ ```bash
98
+ npm install
99
+ npm run build
100
+ npm test
101
+ ```
102
+
103
+ Two terminals for the web console:
104
+
105
+ ```bash
106
+ npm run dev # rebuild on save
107
+ npm run dev:web # restart the console on http://127.0.0.1:9099
108
+ ```
109
+
110
+ ## Security
111
+
112
+ - Passwords stay in memory; keys and certificates are stored with restricted
113
+ permissions.
114
+ - Deployment assets run as root on the target VPS. Only point `--assets-repo`
115
+ at a repository you control.
116
+ - SSH host keys are pinned. A reinstalled VPS is accepted once, logged and re-pinned.
117
+ - The web console binds to `127.0.0.1` unless you choose another host.
118
+ - One runtime dependency: `ssh2`.
119
+
120
+ ## License
121
+
122
+ MIT
@@ -0,0 +1,368 @@
1
+ #!/usr/bin/env bash
2
+ # ===================================================================
3
+ # bond-inspect.sh - explain a bond_inc.sh refusal without signing anything
4
+ #
5
+ # bond_inc.sh keeps exactly ONE signed increase in
6
+ # $OCTRA_DATA_DIR/validator-control/bond-increase.json
7
+ # Every later run compares that record with the arguments and the live
8
+ # registry; anything that differs is refused with
9
+ # event = bond_inc status = refused reason = "saved increase differs;
10
+ # inspect before requesting another increase"
11
+ # This script is read-only (no key is used, no transaction is submitted). It
12
+ # prints the saved record, the live registry view and a field-by-field verdict
13
+ # so the next command can be chosen on purpose.
14
+ #
15
+ # Usage:
16
+ # sudo bash bond-inspect.sh # inspect the saved increase as-is
17
+ # sudo bash bond-inspect.sh -a 20 # ... and compare with a planned +20 OCT
18
+ # sudo bash bond-inspect.sh -a 20 -w /opt/octra/libv_litecore/.keys/validator/wallet.json
19
+ #
20
+ # Options:
21
+ # -a, --amount <oct> the amount you would pass to `bond_inc.sh -a`
22
+ # -w, --wallet <path> wallet.json (default: <config dir>/wallet.json)
23
+ # --config <path> node.env (default: $NODE_SRC_DIR/.keys/validator/node.env)
24
+ # ===================================================================
25
+ set -euo pipefail
26
+ source "$(dirname "$0")/config.sh"
27
+
28
+ if [[ $EUID -ne 0 ]]; then
29
+ err "Run as root: sudo bash $0"
30
+ exit 1
31
+ fi
32
+
33
+ AMOUNT=""
34
+ CONFIG="${NODE_SRC_DIR}/.keys/validator/node.env"
35
+ WALLET="${NODE_SRC_DIR}/.keys/validator/wallet.json"
36
+
37
+ while (( $# )); do
38
+ case "$1" in
39
+ -a|--amount) AMOUNT="${2:-}"; shift 2 ;;
40
+ -w|--wallet) WALLET="${2:-}"; shift 2 ;;
41
+ --config) CONFIG="${2:-}"; shift 2 ;;
42
+ -h|--help) sed -n '3,23p' "$0"; exit 0 ;;
43
+ *) err "Unknown argument: $1"; exit 1 ;;
44
+ esac
45
+ done
46
+
47
+ [[ -f "$CONFIG" ]] || { err "node.env not found: $CONFIG"; exit 1; }
48
+ [[ -f "$WALLET" ]] || { err "wallet not found: $WALLET"; exit 1; }
49
+
50
+ TMP=$(mktemp /tmp/bond-inspect.XXXXXX.py)
51
+ cleanup() { rm -f "$TMP"; }
52
+ trap cleanup EXIT
53
+
54
+ cat > "$TMP" <<'PY'
55
+ # Read-only inspection of a saved bond increase. Mirrors the guards of
56
+ # controls/lib (validator_enroll.committed_enrollment, bond_inc.identity,
57
+ # bond_inc.receipt) instead of importing them: the node modules are re-checked
58
+ # by bond_inc.sh itself when the real payment is signed.
59
+ import json
60
+ import re
61
+ import sys
62
+ import time
63
+ import urllib.request
64
+ from pathlib import Path
65
+
66
+ FEE = 1000
67
+ STAGES = {"prepared", "sent", "accepted"}
68
+
69
+ def say(text = ""):
70
+ print("[bond] " + text if text else "[bond] ")
71
+
72
+ def fail(text):
73
+ print("[bond] ERROR: " + text, file = sys.stderr)
74
+ raise SystemExit(1)
75
+
76
+ def oct_from_raw(raw):
77
+ if type(raw) is not int or raw < 0:
78
+ return "?"
79
+ whole, fraction = divmod(raw, 1000000)
80
+ return (f"{whole}.{fraction:06d}".rstrip("0").rstrip(".") or "0")
81
+
82
+ def parse_env(path):
83
+ values = {}
84
+ for line in path.read_text(encoding = "utf-8").splitlines():
85
+ line = line.strip()
86
+ if not line or line.startswith("#") or "=" not in line:
87
+ continue
88
+ key, _, value = line.partition("=")
89
+ value = value.strip()
90
+ if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
91
+ value = value[1:-1]
92
+ values[key.strip()] = value
93
+ return values
94
+
95
+ def call(url, method, params, *, missing = False, timeout = 10):
96
+ body = json.dumps({"jsonrpc": "2.0", "id": int(time.time() * 1000) % 1000000,
97
+ "method": method, "params": params}).encode("utf-8")
98
+ request = urllib.request.Request(url, data = body, method = "POST", headers = {
99
+ "Accept": "application/json", "Content-Type": "application/json",
100
+ "User-Agent": "octra-validator-controls/1"})
101
+ try:
102
+ with urllib.request.urlopen(request, timeout = timeout) as response:
103
+ payload = json.loads(response.read())
104
+ except Exception as error:
105
+ fail("the local node RPC is unreachable at " + url + " (" + str(error) + ")")
106
+ if not isinstance(payload, dict):
107
+ fail("invalid RPC response from " + url)
108
+ if payload.get("error") is not None:
109
+ error = payload["error"]
110
+ if missing and isinstance(error, dict) and type(error.get("code")) is int and error["code"] == 112:
111
+ return None
112
+ fail("RPC " + method + " rejected: " + json.dumps(error, separators = (",", ":")))
113
+ if "result" not in payload:
114
+ fail("RPC " + method + " returned no result")
115
+ if missing and payload["result"] is None:
116
+ return None
117
+ return payload["result"]
118
+
119
+ def raw_amount(text):
120
+ if not re.fullmatch(r"[0-9]+(?:\.[0-9]{1,6})?", text):
121
+ fail("amount must be OCT with at most six decimal places")
122
+ whole, _, fraction = text.partition(".")
123
+ return int(whole) * 1000000 + int((fraction or "").ljust(6, "0"))
124
+
125
+ def integer(value):
126
+ if type(value) is int:
127
+ return value
128
+ if isinstance(value, str) and value.isascii() and value.isdecimal():
129
+ return int(value)
130
+ return None
131
+
132
+ def main():
133
+ config = Path(sys.argv[1])
134
+ requested_text = (sys.argv[2] or "").strip()
135
+ wallet_path = Path(sys.argv[3])
136
+ node_dir = Path(sys.argv[4]).expanduser() if len(sys.argv) > 4 else Path("/opt/octra/libv_litecore")
137
+ values = parse_env(config)
138
+ if "OCTRA_DATA_DIR" not in values:
139
+ fail("OCTRA_DATA_DIR is missing from " + str(config))
140
+ port = values.get("OCTRA_API_PORT", "8080")
141
+ chain = values.get("OCTRA_CHAIN_ID", "")
142
+ data = Path(values["OCTRA_DATA_DIR"])
143
+ control = data / "validator-control"
144
+ url = "http://127.0.0.1:" + port + "/rpc"
145
+ try:
146
+ wallet = json.loads(wallet_path.read_text(encoding = "utf-8"))
147
+ address, public = wallet["address"], wallet["pub"]
148
+ except (OSError, ValueError, KeyError, TypeError):
149
+ fail("invalid wallet file: " + str(wallet_path))
150
+ requested = raw_amount(requested_text) if requested_text else None
151
+ if requested is not None and requested < 1000000:
152
+ fail("bond increase must be at least 1 OCT")
153
+
154
+ say("config : " + str(config))
155
+ say("control dir : " + str(control))
156
+ say("rpc : " + url)
157
+
158
+ runtime = call(url, "octra_runtimeVersion", [])
159
+ enrollment = call(url, "octra_validatorEnrollment", [])
160
+ balance = call(url, "octra_balance", [address])
161
+ if not isinstance(runtime, dict) or not isinstance(enrollment, dict) or not isinstance(balance, dict):
162
+ fail("the local node returned an unexpected RPC shape")
163
+ head = integer(enrollment.get("head_epoch"))
164
+ bond = integer(enrollment.get("bond"))
165
+ bonded = integer(enrollment.get("bonded_epoch"))
166
+ ready = integer(enrollment.get("ready_epoch"))
167
+ nonce = integer(balance.get("nonce"))
168
+ pending = integer(balance.get("pending_nonce"))
169
+ funds = integer(balance.get("balance_raw"))
170
+
171
+ say("chain : " + str(chain) + (" (local node matches)"
172
+ if runtime.get("chain_id") == chain else " (local node chain DIFFERS: " + str(runtime.get("chain_id")) + ")"))
173
+ say("validator : " + address + (" (local node matches)" if runtime.get("validator") == address
174
+ else " (local node serves " + str(runtime.get("validator")) + ")"))
175
+ say(f"registry : state={enrollment.get('state')} head={head} bond={oct_from_raw(bond)} OCT "
176
+ f"({bond}) bonded_epoch={bonded} ready_epoch={ready}")
177
+ say(f"nonces : nonce={nonce} pending_nonce={pending}"
178
+ + ("" if nonce == pending else " <- a transaction of this wallet is still pending"))
179
+ say(f"balance : {oct_from_raw(funds)} OCT ({funds})")
180
+
181
+ path = control / "bond-increase.json"
182
+ archives = sorted(control.glob("bond-increase-*.json")) if control.is_dir() else []
183
+ record = None
184
+ if path.is_file():
185
+ try:
186
+ record = json.loads(path.read_text(encoding = "utf-8"))
187
+ except (OSError, ValueError) as error:
188
+ fail("the saved increase is not valid JSON: " + str(path) + " (" + str(error) + ")")
189
+ if not isinstance(record, dict):
190
+ fail("the saved increase is not a JSON object: " + str(path))
191
+ say("saved : " + str(path))
192
+ else:
193
+ say("saved : none (" + str(path) + ")")
194
+ for archive in archives:
195
+ say(" leftover archive: " + archive.name)
196
+ if archives:
197
+ fail("bond_inc.sh refuses with \"saved increase missing; restore control records\" "
198
+ "while an archive exists and bond-increase.json does not")
199
+
200
+ user = values.get("NODE_USER", "octra")
201
+ command = "su - " + user + " -c \"cd '" + str(node_dir) + "' && sh ./bond_inc.sh"
202
+ if record is None:
203
+ amount_hint = oct_from_raw(requested) if requested is not None else "<OCT>"
204
+ say("")
205
+ say("verdict : no saved increase yet - the next run signs a fresh payment")
206
+ if nonce is not None and pending is not None and nonce != pending:
207
+ say("blocked : the mempool still holds a transaction of this wallet "
208
+ f"(nonce {nonce} vs pending {pending}); nothing can be signed")
209
+ if requested is not None and type(funds) is int and funds < requested + FEE:
210
+ say(f"blocked : balance {oct_from_raw(funds)} OCT does not cover "
211
+ f"{oct_from_raw(requested)} OCT + {oct_from_raw(FEE)} OCT fee")
212
+ say("next : " + command + " -a " + amount_hint + " -w '" + str(wallet_path) + "'\"")
213
+ return
214
+
215
+
216
+ saved = integer(record.get("amount"))
217
+ tx = record.get("tx") if isinstance(record.get("tx"), dict) else {}
218
+ say(f"record : amount={oct_from_raw(saved)} OCT ({saved}) stage={record.get('stage')} "
219
+ f"before={oct_from_raw(record.get('before'))} OCT head={record.get('head')} "
220
+ f"bonded={record.get('bonded')} ready={record.get('ready')}")
221
+ say(f"record tx : {record.get('tx_hash')} nonce={tx.get('nonce')} ou={tx.get('ou')} "
222
+ f"timestamp={tx.get('timestamp')}")
223
+
224
+ state = None
225
+ observed = call(url, "octra_transaction", [record["tx_hash"]], missing = True) \
226
+ if isinstance(record.get("tx_hash"), str) else None
227
+ if isinstance(observed, dict):
228
+ state = observed.get("status")
229
+ differ = [name for name, want in (("tx_hash", record.get("tx_hash")),
230
+ ("from", tx.get("from")), ("amount_raw", tx.get("amount")),
231
+ ("nonce", tx.get("nonce")), ("op_type", tx.get("op_type")))
232
+ if observed.get(name) != want]
233
+ if observed.get("to_", observed.get("to")) != tx.get("to_"):
234
+ differ.append("to")
235
+ say("receipt : status=" + str(state)
236
+ + (" fields match" if not differ else " fields DIFFER: " + ",".join(differ))
237
+ + (" epoch=" + str(observed.get("epoch")) if observed.get("epoch") is not None else ""))
238
+ if "to_" not in observed:
239
+ say("note : this node returns the field `to`, while bond_inc.sh receipt() reads")
240
+ say(" `to_`, so a re-run or --new refuses with \"bond transaction")
241
+ say(" receipt differs\" once the identity checks pass")
242
+ else:
243
+ say("receipt : not on chain (the payment was never accepted)")
244
+
245
+ def rows_for(target):
246
+ rows = []
247
+ for key, want in (("chain", chain), ("address", address), ("pubkey", public),
248
+ ("amount", target), ("bonded", bonded), ("ready", ready)):
249
+ actual = record.get(key)
250
+ rows.append((type(actual) is type(want) and actual == want, key, actual, want))
251
+ before = record.get("before")
252
+ rows.append((type(before) is int and before >= 1, "before", before, "int >= 1"))
253
+ saved_head = record.get("head")
254
+ rows.append((type(saved_head) is int and type(head) is int and 0 <= saved_head <= head,
255
+ "head", saved_head, "<= " + str(head)))
256
+ rows.append((record.get("stage") in STAGES, "stage", record.get("stage"),
257
+ "prepared|sent|accepted"))
258
+ return rows
259
+
260
+ def report(rows, label):
261
+ bad = [row for row in rows if not row[0]]
262
+ if not bad:
263
+ say(label + ": all " + str(len(rows)) + " identity checks match")
264
+ return True
265
+ say(label + ": " + str(len(bad)) + "/" + str(len(rows)) + " identity checks differ")
266
+ for _, key, actual, want in bad:
267
+ if key == "amount":
268
+ say(f" amount: saved {oct_from_raw(actual)} OCT ({actual}) != "
269
+ f"wanted {oct_from_raw(want)} OCT ({want})")
270
+ else:
271
+ say(f" {key}: saved {actual!r} != registry/argument {want!r}")
272
+ return False
273
+
274
+ target = requested if requested is not None else saved
275
+ plain_rows = rows_for(target)
276
+ plain = report(plain_rows, "plain run")
277
+ plain_bad = {row[1] for row in plain_rows if not row[0]}
278
+ if requested is not None and requested != saved:
279
+ new = report(rows_for(saved), "--new run")
280
+ else:
281
+ new = plain
282
+ say("")
283
+
284
+ if plain and state == "confirmed":
285
+ say("verdict : the saved increase is already confirmed (bond "
286
+ + oct_from_raw(bond) + " OCT on chain); nothing was signed and nothing changed")
287
+ say("next : to add MORE bond you must use --new:")
288
+ say(" " + command + " -a "
289
+ + (oct_from_raw(requested) if requested is not None else "<OCT>")
290
+ + " -w '" + str(wallet_path) + "' --new\"")
291
+ return
292
+ if plain and state in {"pending", "staging"}:
293
+ say("verdict : the saved payment is " + str(state) + " - it is already in flight, never sign again")
294
+ say("next : wait for tx " + str(record.get("tx_hash")) + " to reach status confirmed")
295
+ return
296
+ if plain and state in {"rejected", "dropped"}:
297
+ say("verdict : the saved payment was " + str(state) + " on chain, nothing was added")
298
+ say("next : archive the record OUTSIDE the control dir and sign a fresh payment:")
299
+ say(" install -d -m 700 " + str(data) + ".bond-archive")
300
+ say(" mv '" + str(path) + "' " + str(data) + ".bond-archive/bond-increase."
301
+ + str(int(time.time())) + ".json")
302
+ say(" then: " + command + " -a "
303
+ + (oct_from_raw(requested) if requested is not None else oct_from_raw(saved))
304
+ + " -w '" + str(wallet_path) + "'\"")
305
+ return
306
+ elif plain and record.get("stage") != "prepared":
307
+ say("verdict : the saved payment was submitted but its outcome is unknown")
308
+ say("next : " + command + " -a " + oct_from_raw(saved) + " -w '" + str(wallet_path)
309
+ + "' --retry\" (resubmits the SAME transaction)")
310
+ return
311
+ elif plain and record.get("stage") == "prepared":
312
+ say("verdict : the saved payment was signed but never submitted")
313
+ say("next : " + command + " -a " + oct_from_raw(saved) + " -w '" + str(wallet_path)
314
+ + "'\" (submits the saved payment)")
315
+ return
316
+
317
+ if new and state != "confirmed":
318
+ say("verdict : the previous increase is NOT confirmed yet (receipt "
319
+ + str(state or "absent") + "), so --new refuses until it is")
320
+ say("next : wait - watch `sh controls/stat.sh | grep validator_bond` and the receipt of")
321
+ say(" " + str(record.get("tx_hash")))
322
+ elif new:
323
+ if type(funds) is int and requested is not None and funds < requested + FEE:
324
+ say(f"verdict : ready for --new, but the balance {oct_from_raw(funds)} OCT does not cover "
325
+ f"{oct_from_raw(requested)} OCT + fee")
326
+ elif nonce is not None and pending is not None and nonce != pending:
327
+ say("verdict : ready for --new, but the mempool still holds a transaction "
328
+ f"(nonce {nonce} vs pending {pending}); wait for it to land")
329
+ else:
330
+ say("verdict : the previous increase is confirmed - a new one can be signed")
331
+ say("next : " + command + " -a "
332
+ + (oct_from_raw(requested) if requested is not None else "<OCT>")
333
+ + " -w '" + str(wallet_path) + "' --new\"")
334
+ if isinstance(observed, dict) and "to_" not in observed:
335
+ say("warning : --new still goes through receipt(), which reads `to_`; if it is")
336
+ say(" refused with \"bond transaction receipt differs\", archive the saved")
337
+ say(" record outside the control dir and sign a fresh increase instead")
338
+ else:
339
+ if plain_bad == {"amount"}:
340
+ say("verdict : the saved payment is for " + oct_from_raw(saved) + " OCT and the amount")
341
+ say(" passed is different - bond_inc.sh treats it as the SAME operation")
342
+ say(" (a different amount needs --new once the saved tx is confirmed)")
343
+ else:
344
+ say("verdict : the saved record no longer matches this node/registry (see the")
345
+ say(" differing fields above), so it cannot be replayed")
346
+ if state in {"pending", "staging"}:
347
+ say("next : keep the file, wait for tx " + str(record.get("tx_hash")))
348
+ elif state == "confirmed":
349
+ say("next : the saved payment is confirmed, so the file can be archived OUTSIDE the")
350
+ say(" control dir (a bond-increase-*.json left behind makes bond_inc.sh refuse")
351
+ say(" with \"saved increase missing; restore control records\"):")
352
+ say(" install -d -m 700 " + str(data) + ".bond-archive")
353
+ say(" mv '" + str(path) + "' " + str(data) + ".bond-archive/bond-increase."
354
+ + str(int(time.time())) + ".json")
355
+ say(" then re-run bond_inc.sh for the new amount (no --new needed)")
356
+ else:
357
+ say("next : do NOT delete the file yet - check the tx first: "
358
+ + str(record.get("tx_hash")))
359
+
360
+ if __name__ == "__main__":
361
+ try:
362
+ main()
363
+ except (OSError, KeyError, ValueError, TypeError) as error:
364
+ fail(str(error))
365
+ PY
366
+
367
+ python3 -B "$TMP" "$CONFIG" "$AMOUNT" "$WALLET" "$NODE_SRC_DIR"
368
+