diff --git a/scripts/finalize-axebc2-0.1.11-dev.sh b/scripts/finalize-axebc2-0.1.11-dev.sh index ea7db1c..a8feea8 100755 --- a/scripts/finalize-axebc2-0.1.11-dev.sh +++ b/scripts/finalize-axebc2-0.1.11-dev.sh @@ -20,6 +20,8 @@ core_tag="ghcr.io/willitmod/bitcoinii-core:31.1.0-rc.cdf44542dde2" core_digest="sha256:8875917ece57668fe9925d40a256ce8d429a3071511bb555d4ace1fa4370afc6" os_version="v0.7.12-dev" os_bundle_sha256="11a35e68ab169eb0446485992a57b33fae018a92020b7d86bbf9a005571377af" +dev_store_revision="249ab61506dc09c2151d39e2b210f5f18d75ff21" +dev_compose_sha256="93ceba92069947f47d650a5fb32205836fe070d83707f36912a2e0e83beb1244" fail() { echo "ERROR: $*" >&2; exit 1; } [[ "$app_digest" =~ ^sha256:[0-9a-f]{64}$ ]] || fail "app digest is not an exact sha256 digest" [[ "$core_digest" =~ ^sha256:[0-9a-f]{64}$ ]] || fail "Core digest is not an exact sha256 digest" @@ -77,11 +79,19 @@ grep -F _DIGEST_REQUIRED "$tmp" >/dev/null && fail "unresolved digest sentinel r grep -Fx " image: $app_tag@$app_digest" "$tmp" >/dev/null || fail "app reference is incorrect" grep -Fx " image: $core_tag@$core_digest" "$tmp" >/dev/null || fail "Core service reference is incorrect" grep -Fx " BTC2D_IMAGE: \"$core_tag@$core_digest\"" "$tmp" >/dev/null || fail "BTC2D_IMAGE is incorrect" +rendered_compose_sha256="$(python3 - "$tmp" <<'PY' +import hashlib, sys +with open(sys.argv[1], "rb") as handle: + print(hashlib.sha256(handle.read()).hexdigest()) +PY +)" +[[ "$rendered_compose_sha256" == "$dev_compose_sha256" ]] || + fail "finalized DEV Compose SHA-256 differs from the accepted recipe" evidence_tmp="$(mktemp "${evidence_output}.finalize.XXXXXX")" -python3 - "$evidence_tmp" "$app_tag" "$app_digest" "$app_revision" "$app_candidate_run" "$core_tag" "$core_digest" "$core_revision" "$os_version" "$os_bundle_sha256" <<'PY' +python3 - "$evidence_tmp" "$app_tag" "$app_digest" "$app_revision" "$app_candidate_run" "$core_tag" "$core_digest" "$core_revision" "$os_version" "$os_bundle_sha256" "$dev_store_revision" "$dev_compose_sha256" <<'PY' import json,sys -path,app_image,app_digest,revision,app_candidate_run,core_image,core_digest,core_revision,os_version,os_bundle_sha256=sys.argv[1:] +path,app_image,app_digest,revision,app_candidate_run,core_image,core_digest,core_revision,os_version,os_bundle_sha256,dev_store_revision,dev_compose_sha256=sys.argv[1:] payload = { "schema": 1, "result": "RECORD_passed_AFTER_LIVE_DEV_ACCEPTANCE", @@ -96,6 +106,8 @@ payload = { "core_candidate_run": 33675068951, "tested_os_version": os_version, "tested_os_bundle_sha256": os_bundle_sha256, + "dev_store_revision": dev_store_revision, + "dev_compose_sha256": dev_compose_sha256, "tested_on": "RECORD_TEST_NODE", "tested_at": "RECORD_ISO_8601_TIMESTAMP", "acceptance": { @@ -129,6 +141,7 @@ payload = { "pending_payout_revalidation_passed": "RECORD_BOOLEAN", "main_payout_banner_hidden": "RECORD_BOOLEAN", "ckpool_sharelog_ownership_repaired": "RECORD_BOOLEAN", + "pool_config_directory_writable": "RECORD_BOOLEAN", "telemetry_disabled": "RECORD_BOOLEAN", "p2p_port_unpublished": "RECORD_BOOLEAN", "natpmp_disabled": "RECORD_BOOLEAN", diff --git a/scripts/validate-axebc2-core31-dev.py b/scripts/validate-axebc2-core31-dev.py index 6b9330b..969d617 100644 --- a/scripts/validate-axebc2-core31-dev.py +++ b/scripts/validate-axebc2-core31-dev.py @@ -1,5 +1,7 @@ #!/usr/bin/env python3 from pathlib import Path +import datetime +import hashlib import importlib.util import json import os @@ -15,6 +17,10 @@ ROOT = Path(__file__).resolve().parents[1] APP = ROOT / "willitmod-dev-bc2" +APP_DIGEST = "sha256:23a7962e223da5549eba52697c6f4cfa16ab74cba935c68c48148a4c515302b4" +DEV_STORE_REVISION = "249ab61506dc09c2151d39e2b210f5f18d75ff21" +DEV_COMPOSE_SHA256 = "93ceba92069947f47d650a5fb32205836fe070d83707f36912a2e0e83beb1244" +DEV_STORE_COMMITTED_AT = datetime.datetime.fromisoformat("2026-09-04T17:11:52+00:00") def require(condition, message): @@ -22,7 +28,9 @@ def require(condition, message): raise SystemExit(message) -compose = (APP / "docker-compose.yml").read_text(encoding="utf-8") +compose_path = APP / "docker-compose.yml" +compose_bytes = compose_path.read_bytes() +compose = compose_bytes.decode("utf-8") parser = argparse.ArgumentParser() parser.add_argument("--phase", required=True, choices=("prefinalization", "finalized")) phase = parser.parse_args().phase @@ -34,6 +42,17 @@ def require(condition, message): node_config = (APP / "data/templates/bitcoinII.conf.template").read_text(encoding="utf-8") evidence = json.loads((APP / "DEV-ACCEPTANCE-EVIDENCE.json").read_text(encoding="utf-8")) +# Hash the exact finalized recipe in either lifecycle phase. In prefinalization +# there is exactly one sentinel; in finalization this replacement is a no-op. +finalized_compose_bytes = compose_bytes.replace( + b"APP_CANDIDATE_DIGEST_REQUIRED", APP_DIGEST.removeprefix("sha256:").encode() +) +computed_compose_sha256 = hashlib.sha256(finalized_compose_bytes).hexdigest() +require( + computed_compose_sha256 == DEV_COMPOSE_SHA256, + "DEV Compose content differs from the recipe accepted on 10.10.10.235", +) + require('version: "0.1.11-dev"' in manifest, "manifest must be 0.1.11-dev") require(evidence.get("app_version") == "0.1.11-dev", "evidence must name the 0.1.11 DEV app version") require( @@ -47,7 +66,7 @@ def require(condition, message): ) require( evidence.get("app_digest") - == "sha256:23a7962e223da5549eba52697c6f4cfa16ab74cba935c68c48148a4c515302b4", + == APP_DIGEST, "evidence must name the exact application index digest", ) require(evidence.get("app_candidate_run") == 33895447789, "evidence must name the application candidate workflow run") @@ -55,8 +74,9 @@ def require(condition, message): evidence.get("core_image") == "ghcr.io/willitmod/bitcoinii-core:31.1.0-rc.cdf44542dde2" and evidence.get("core_digest") == "sha256:8875917ece57668fe9925d40a256ce8d429a3071511bb555d4ace1fa4370afc6" - and evidence.get("core_source_revision") == "cdf44542dde255648008249d187fafc15f3a2f09", - "evidence must retain the accepted Core 31 tag, digest, and source revision", + and evidence.get("core_source_revision") == "cdf44542dde255648008249d187fafc15f3a2f09" + and evidence.get("core_candidate_run") == 33675068951, + "evidence must retain the accepted Core 31 tag, digest, source revision, and candidate run", ) require("Requires 5tratumOS 0.7.12" in manifest, "OS prerequisite must be disclosed") require(evidence.get("tested_os_version") == "v0.7.12-dev", "evidence must name the tested DEV OS release") @@ -65,6 +85,123 @@ def require(condition, message): == "11a35e68ab169eb0446485992a57b33fae018a92020b7d86bbf9a005571377af", "evidence must be bound to the exact verified v0.7.12-dev bundle", ) +require( + evidence.get("dev_store_revision") == DEV_STORE_REVISION, + "evidence must name the exact corrected DEV store revision", +) +require( + evidence.get("dev_compose_sha256") == DEV_COMPOSE_SHA256 + and evidence.get("dev_compose_sha256") == computed_compose_sha256, + "evidence must be bound to the exact corrected DEV Compose recipe", +) + + +def parse_utc_timestamp(value, label): + try: + parsed = datetime.datetime.fromisoformat(str(value).replace("Z", "+00:00")) + except ValueError: + raise SystemExit(f"{label} must be an ISO-8601 timestamp") + require(parsed.tzinfo is not None, f"{label} must include a timezone") + return parsed.astimezone(datetime.timezone.utc) + + +if phase == "finalized": + require(type(evidence.get("schema")) is int and evidence["schema"] == 1, "finalized DEV evidence schema must be 1") + require(evidence.get("result") == "passed", "finalized DEV evidence must have passed") + require(evidence.get("tested_on") == "10.10.10.235", "finalized DEV evidence must name the test node") + tested_at = parse_utc_timestamp(evidence.get("tested_at"), "tested_at") + acceptance = evidence.get("acceptance") + require(isinstance(acceptance, dict), "finalized DEV evidence requires structured acceptance observations") + observed_at = parse_utc_timestamp(acceptance.get("observed_at"), "acceptance observed_at") + require( + tested_at >= DEV_STORE_COMMITTED_AT and observed_at >= DEV_STORE_COMMITTED_AT, + "finalized DEV acceptance must post-date the corrected store recipe", + ) + require(observed_at == tested_at, "tested_at and acceptance observed_at must identify the same run") + + true_gates = ( + "migration_required_marker_absent", + "migration_started_marker_valid", + "migration_complete_marker_valid", + "verifychain_passed", + "payout_configured", + "payout_preserved", + "app_ui_privacy_passed", + "payout_validation_passed", + "invalid_payout_rejected_without_mutation", + "rpc_unavailable_rejected_without_mutation", + "pending_payout_revalidation_passed", + "main_payout_banner_hidden", + "ckpool_sharelog_ownership_repaired", + "pool_config_directory_writable", + "telemetry_disabled", + "p2p_port_unpublished", + "natpmp_disabled", + "post_completion_restart_passed", + "reindex_not_repeated", + "app_rollback_rejected", + "os_rollback_rejected", + ) + missing_true_gates = [key for key in true_gates if acceptance.get(key) is not True] + require(not missing_true_gates, "required acceptance gates are not true: " + ", ".join(missing_true_gates)) + require( + acceptance.get("chain") == "main" + and type(acceptance.get("competing_valid_tips")) is int + and acceptance["competing_valid_tips"] == 0, + "main chain must have no competing valid tips", + ) + require(acceptance.get("core_version") == 310100, "exact Core 31.1.0 version was not observed") + require( + acceptance.get("checkpoint_height") == 57752 + and acceptance.get("checkpoint_hash") + == "000000000000000013ceffe797280c57f75a5b9f1d9e70c3503584058c322576", + "official ShockWave checkpoint observation is invalid", + ) + hex64 = lambda value: isinstance(value, str) and bool(re.fullmatch(r"[0-9a-f]{64}", value)) + minimum_chainwork = "0000000000000000000000000000000000000000000000959028194ff1139272" + require( + hex64(acceptance.get("chainwork")) and acceptance["chainwork"] >= minimum_chainwork, + "observed chainwork is below the accepted minimum", + ) + progress = acceptance.get("verification_progress") + require( + acceptance.get("ibd") is False + and isinstance(progress, (int, float)) + and not isinstance(progress, bool) + and progress >= 0.999999, + "node synchronization evidence is incomplete", + ) + blocks = acceptance.get("blocks") + require( + type(blocks) is int + and blocks >= 57752 + and blocks == acceptance.get("headers") + and blocks == acceptance.get("explorer_common_height"), + "node and explorer heights must match at or beyond the checkpoint", + ) + require( + hex64(acceptance.get("best_block_hash")) + and acceptance.get("best_block_hash") == acceptance.get("explorer_common_hash"), + "node and explorer hashes must match", + ) + require( + type(acceptance.get("outbound_core31_peers")) is int + and acceptance["outbound_core31_peers"] >= 3, + "fewer than three outbound Core 31 peers were observed", + ) + require(acceptance.get("verifychain_level") == 4, "verifychain level 4 was not recorded") + require(acceptance.get("pool_stratum_result") == "passed", "pool/Stratum acceptance did not pass") +else: + require( + evidence.get("result") == "RECORD_passed_AFTER_LIVE_DEV_ACCEPTANCE", + "prefinalization evidence must remain an explicit acceptance template", + ) + acceptance = evidence.get("acceptance") + require(isinstance(acceptance, dict), "prefinalization evidence template requires acceptance fields") + require( + acceptance.get("pool_config_directory_writable") == "RECORD_BOOLEAN", + "prefinalization evidence must prompt for the uid-1000 pool-config write probe", + ) require('"2345:3333/tcp"' in compose, "Stratum host port 2345 must be retained") require("SUPPORT_CHECKIN_ENABLED: \"false\"" in compose, "telemetry must default off") require("create_host_path: false" in compose, "build metadata bind must fail closed") diff --git a/tests/test_axebc2_dev_finalizer.py b/tests/test_axebc2_dev_finalizer.py index 098d26b..31abcfa 100644 --- a/tests/test_axebc2_dev_finalizer.py +++ b/tests/test_axebc2_dev_finalizer.py @@ -1,4 +1,5 @@ import json +import hashlib import os import re from pathlib import Path @@ -10,10 +11,12 @@ ROOT = Path(__file__).resolve().parents[1] SCRIPT = ROOT / "scripts/finalize-axebc2-0.1.11-dev.sh" COMPOSE = ROOT / "willitmod-dev-bc2/docker-compose.yml" -APP_DIGEST = "sha256:" + "a" * 64 +APP_DIGEST = "sha256:23a7962e223da5549eba52697c6f4cfa16ab74cba935c68c48148a4c515302b4" CORE_DIGEST = "sha256:8875917ece57668fe9925d40a256ce8d429a3071511bb555d4ace1fa4370afc6" CORE_TAG = "31.1.0-rc.cdf44542dde2" OS_BUNDLE_SHA256 = "11a35e68ab169eb0446485992a57b33fae018a92020b7d86bbf9a005571377af" +DEV_STORE_REVISION = "249ab61506dc09c2151d39e2b210f5f18d75ff21" +DEV_COMPOSE_SHA256 = "93ceba92069947f47d650a5fb32205836fe070d83707f36912a2e0e83beb1244" class AxeBC2DevFinalizerTests(unittest.TestCase): def setUp(self): @@ -77,6 +80,7 @@ def test_anonymous_candidate_checks_finalize_and_emit_evidence(self): result=self.run_it(); self.assertEqual(result.returncode,0,result.stderr) compose=(self.root/"willitmod-dev-bc2/docker-compose.yml").read_text(encoding="utf-8") self.assertNotIn("_DIGEST_REQUIRED",compose) + self.assertEqual(hashlib.sha256(compose.encode()).hexdigest(),DEV_COMPOSE_SHA256) core_ref="ghcr.io/willitmod/bitcoinii-core:"+CORE_TAG+"@"+CORE_DIGEST self.assertEqual(compose.count(core_ref),2) evidence=json.loads((self.root/"willitmod-dev-bc2/DEV-ACCEPTANCE-EVIDENCE.json").read_text(encoding="utf-8")) @@ -87,12 +91,25 @@ def test_anonymous_candidate_checks_finalize_and_emit_evidence(self): self.assertEqual(evidence["core_candidate_run"],33675068951) self.assertEqual(evidence["tested_os_version"],"v0.7.12-dev") self.assertEqual(evidence["tested_os_bundle_sha256"],OS_BUNDLE_SHA256) + self.assertEqual(evidence["dev_store_revision"],DEV_STORE_REVISION) + self.assertEqual(evidence["dev_compose_sha256"],DEV_COMPOSE_SHA256) + self.assertEqual(evidence["acceptance"]["pool_config_directory_writable"],"RECORD_BOOLEAN") self.assertEqual(evidence["app_digest"],APP_DIGEST); self.assertEqual(evidence["core_digest"],CORE_DIGEST) calls=self.log.read_text(encoding="utf-8") self.assertEqual(calls.count("--platform linux/amd64"),2); self.assertEqual(calls.count("--platform linux/arm64"),2) self.assertNotIn("buildx", calls) self.assertTrue(all("--host unix:///tmp/test-colima.sock --config" in line for line in calls.splitlines())) + def test_recipe_drift_fails_before_compose_or_evidence_mutation(self): + compose=self.root/"willitmod-dev-bc2/docker-compose.yml" + compose.write_bytes(compose.read_bytes()+b"\n# unexpected recipe drift\n") + original=compose.read_bytes() + result=self.run_it() + self.assertNotEqual(result.returncode,0) + self.assertIn("finalized DEV Compose SHA-256 differs",result.stderr) + self.assertEqual(compose.read_bytes(),original) + self.assertFalse((self.root/"willitmod-dev-bc2/DEV-ACCEPTANCE-EVIDENCE.json").exists()) + def test_bad_explicit_docker_host_fails_before_registry_or_mutation(self): env=os.environ.copy(); env.update({"DOCKER_BIN":str(self.fake),"DOCKER_HOST":"not-an-endpoint","CURL_BIN":str(self.fake_curl),"FAKE_DOCKER_LOG":str(self.log),"FAKE_CURL_LOG":str(self.curl_log),"APP_DIGEST":APP_DIGEST,"CORE_DIGEST":CORE_DIGEST}) result=subprocess.run([str(self.root/"scripts"/SCRIPT.name),APP_DIGEST],env=env,text=True,capture_output=True,check=False) diff --git a/willitmod-dev-bc2/CORE31-DEV-RELEASE.md b/willitmod-dev-bc2/CORE31-DEV-RELEASE.md index be8330b..50f8945 100644 --- a/willitmod-dev-bc2/CORE31-DEV-RELEASE.md +++ b/willitmod-dev-bc2/CORE31-DEV-RELEASE.md @@ -32,13 +32,21 @@ candidate workflow run `33675068951`. Both the DEV finalizer and validator must reject any change to that tag, digest, source revision, or the two-reference invariant. +The corrected DEV recipe was merged at store revision +`249ab61506dc09c2151d39e2b210f5f18d75ff21`. The exact finalized +`docker-compose.yml` tested from that revision has SHA-256 +`93ceba92069947f47d650a5fb32205836fe070d83707f36912a2e0e83beb1244`. +Acceptance evidence and every downstream promotion gate are bound to both +values so evidence from the earlier recipe cannot authorize release. + `scripts/finalize-axebc2-0.1.11-dev.sh` accepts the exact application index digest once the candidate is available. Before editing Compose, it anonymously verifies the application and retained Core tag resolutions, amd64 and arm64 manifests, and digest pulls. It atomically replaces only the application -sentinel and emits an evidence template bound to both exact source revisions. -CI accepts either the complete one-sentinel prefinalization state or the complete -immutable finalization state; partial or mixed states fail. +sentinel, verifies that the resulting recipe has the accepted checksum, and +emits an evidence template bound to both exact source revisions and the corrected +DEV store recipe. CI accepts either the complete one-sentinel prefinalization +state or the complete immutable finalization state; partial or mixed states fail. The test platform remains fixed to the published DEV-only [`v0.7.12-dev`](https://github.com/WillItMod/5tratum/releases/tag/v0.7.12-dev) @@ -68,20 +76,26 @@ competing valid tip at or beyond ShockWave checkpoint height `57,752`. That evidence remains valid for the unchanged Core image, but it does not replace live acceptance of the new application candidate. -## Required 0.1.11 live DEV acceptance - -Exercise installation and update on DEV. Confirm that the existing node chain, -migration markers, pool configuration, payout address, and rollback policy are -preserved; no blockchain reindex is started; sync, Explorer, private UI data, -and the non-submitting Stratum probe work; and telemetry, P2P port, and NAT-PMP -controls remain unchanged. - -Specifically verify the payout fix with Core-accepted mainnet `1...`, `3...`, and -`bc1...` address families. An invalid or wrong-network address and an RPC-not-ready -condition must both leave the saved pool configuration and payout history -unchanged. Any legacy pending-validation record must be rechecked at the bounded -interval and resolve to either validated or a definitive warning. Confirm that a -MAIN/stable channel value no longer displays the misleading payout warning, and -that the one-time conditional CKPool `/www` ownership repair makes existing -sharelog paths writable by uid/gid 1000 without rewriting a current pool config. -Complete `DEV-ACCEPTANCE-EVIDENCE.json` only after those live checks pass. +## Completed 0.1.11 live DEV acceptance + +The corrected recipe was installed through the DEV store on `10.10.10.235` and +accepted at `2026-09-04T17:22:22Z`. The update preserved the existing node chain, +migration markers, pool configuration, payout address, and rollback policy. It +did not start another blockchain reindex. The non-submitting Stratum probe passed, +and telemetry, P2P-port, and NAT-PMP controls remained unchanged. + +Core `310100` reported main-chain height and headers `58,444`, chainwork +`00000000000000000000000000000000000000000000fb2888bffb8c3c655c9c`, and best +block hash `00000000000000001e6ee54b268e62f3f1306a04cdb8f08a30c712e3e1cc3996`. +The official explorer matched that exact height and hash. Level-4 `verifychain` +passed, ten outbound Core 31 peers were observed, and there were no competing +valid tips. + +The payout tests covered Core-accepted mainnet `1...`, `3...`, and `bc1...` +address families. Invalid and RPC-unavailable submissions did not mutate the +saved configuration, bounded pending validation passed, private UI behavior was +retained, and the misleading MAIN payout banner remained hidden. The corrected +initializer left `/data/pool/config` owned by uid/gid 1000; an atomic create and +replace probe from the uid-1000 application container passed. The targeted config +repair did not rewrite the current pool configuration, and the conditional +CKPool `/www` sharelog repair also passed. diff --git a/willitmod-dev-bc2/DEV-ACCEPTANCE-EVIDENCE.json b/willitmod-dev-bc2/DEV-ACCEPTANCE-EVIDENCE.json index 1573e39..2a13857 100644 --- a/willitmod-dev-bc2/DEV-ACCEPTANCE-EVIDENCE.json +++ b/willitmod-dev-bc2/DEV-ACCEPTANCE-EVIDENCE.json @@ -12,10 +12,12 @@ "core_candidate_run": 33675068951, "tested_os_version": "v0.7.12-dev", "tested_os_bundle_sha256": "11a35e68ab169eb0446485992a57b33fae018a92020b7d86bbf9a005571377af", + "dev_store_revision": "249ab61506dc09c2151d39e2b210f5f18d75ff21", + "dev_compose_sha256": "93ceba92069947f47d650a5fb32205836fe070d83707f36912a2e0e83beb1244", "tested_on": "10.10.10.235", - "tested_at": "2026-09-04T17:00:50Z", + "tested_at": "2026-09-04T17:22:22Z", "acceptance": { - "observed_at": "2026-09-04T17:00:50Z", + "observed_at": "2026-09-04T17:22:22Z", "chain": "main", "core_version": 310100, "migration_required_marker_absent": true, @@ -23,14 +25,14 @@ "migration_complete_marker_valid": true, "checkpoint_height": 57752, "checkpoint_hash": "000000000000000013ceffe797280c57f75a5b9f1d9e70c3503584058c322576", - "chainwork": "00000000000000000000000000000000000000000000fb2032889c9b9dee0ebe", + "chainwork": "00000000000000000000000000000000000000000000fb2888bffb8c3c655c9c", "ibd": false, "verification_progress": 1.0, - "blocks": 58443, - "headers": 58443, - "best_block_hash": "000000000000000003804a1aac31af72e0b1e7e00926faa602fc9f3f26e40175", - "explorer_common_height": 58443, - "explorer_common_hash": "000000000000000003804a1aac31af72e0b1e7e00926faa602fc9f3f26e40175", + "blocks": 58444, + "headers": 58444, + "best_block_hash": "00000000000000001e6ee54b268e62f3f1306a04cdb8f08a30c712e3e1cc3996", + "explorer_common_height": 58444, + "explorer_common_hash": "00000000000000001e6ee54b268e62f3f1306a04cdb8f08a30c712e3e1cc3996", "outbound_core31_peers": 10, "competing_valid_tips": 0, "verifychain_level": 4, @@ -45,6 +47,7 @@ "pending_payout_revalidation_passed": true, "main_payout_banner_hidden": true, "ckpool_sharelog_ownership_repaired": true, + "pool_config_directory_writable": true, "telemetry_disabled": true, "p2p_port_unpublished": true, "natpmp_disabled": true,