diff --git a/boot.tests/NodeSetupTests.cs b/boot.tests/NodeSetupTests.cs index d069d77..5141989 100644 --- a/boot.tests/NodeSetupTests.cs +++ b/boot.tests/NodeSetupTests.cs @@ -101,7 +101,9 @@ public void SetupModeAllowsOnlySetupAndHealthPaths() { Assert.IsTrue(NodeSetupPolicy.IsAllowedSetupPath("/setup")); Assert.IsTrue(NodeSetupPolicy.IsAllowedSetupPath("/setup.css")); + Assert.IsTrue(NodeSetupPolicy.IsAllowedSetupPath("/setup.js")); Assert.IsTrue(NodeSetupPolicy.IsAllowedSetupPath("/health/live")); + Assert.IsTrue(NodeSetupPolicy.IsAllowedSetupPath("/health/ready")); Assert.IsFalse(NodeSetupPolicy.IsAllowedSetupPath("/api/mining/share")); Assert.IsFalse(NodeSetupPolicy.IsAllowedSetupPath("/api/peer/session")); Assert.IsFalse(NodeSetupPolicy.IsAllowedSetupPath("/dashboardHub")); diff --git a/boot.tests/ShareAttributionTests.cs b/boot.tests/ShareAttributionTests.cs index bccf308..0e7f92a 100644 --- a/boot.tests/ShareAttributionTests.cs +++ b/boot.tests/ShareAttributionTests.cs @@ -1463,6 +1463,20 @@ public async Task V22SiblingBoundaryRaceConvergesWithoutBranchVotingAndV21DoesNo Assert.AreNotEqual(v21Alice.StateService.GetNetworkStatus().ActiveSnapshotId, v21Bob.StateService.GetNetworkStatus().ActiveSnapshotId); } + [TestMethod] + public void FullReserveImportAllowsOneSnapshotContextPerBoundedProof() + { + var config = new PoolConfig + { + WinnersListSize = 299, + WorkSetReserveMultiplier = 3, + MaxStateBundleHistory = 8 + }; + + Assert.AreEqual(1198, BootProtocolStateService.GetMaxImportedSnapshotContextCount(config)); + Assert.IsTrue(BootProtocolStateService.GetMaxImportedSnapshotContextCount(config) >= 396); + } + [TestMethod] public async Task RegtestSiblingImportAcceptsProofRecordedAgainstExplicitEmptyBootstrapSnapshotAsync() { diff --git a/boot_portal/Models/PoolConfig.cs b/boot_portal/Models/PoolConfig.cs index 2776ec4..5a1bfa1 100644 --- a/boot_portal/Models/PoolConfig.cs +++ b/boot_portal/Models/PoolConfig.cs @@ -23,6 +23,9 @@ public class PoolConfig [JsonPropertyName("pool_payout_script")] public string PoolPayoutScript { get; set; } = string.Empty; + [JsonPropertyName("restart_after_setup")] + public bool RestartAfterSetup { get; set; } = false; + [JsonPropertyName("winners_list_size")] public int WinnersListSize { get; set; } = 299; diff --git a/boot_portal/Pages/Setup.cshtml b/boot_portal/Pages/Setup.cshtml index 168c154..c62ea79 100644 --- a/boot_portal/Pages/Setup.cshtml +++ b/boot_portal/Pages/Setup.cshtml @@ -25,12 +25,26 @@ @if (Model.RestartRequired) { -

The payout address is stored. Restart the GridPool app to start networking and mining services with this configuration.

+ @if (Model.AutomaticRestart) + { +

The payout address is stored. GridPool is restarting with mining and peer services enabled.

+ } + else + { +

The payout address is stored. Restart the GridPool app to start networking and mining services with this configuration.

+ }
Locked payout address @Model.SavedAddress
-

GridPool will remain in setup-only mode until it restarts. No mining work or peer relay is active yet.

+ @if (Model.AutomaticRestart) + { +

Waiting for the node to become ready. This page will open the dashboard automatically.

+ } + else + { +

GridPool will remain in setup-only mode until it restarts. No mining work or peer relay is active yet.

+ } } else { @@ -49,5 +63,9 @@ } + @if (Model.AutomaticRestart) + { + + } diff --git a/boot_portal/Pages/Setup.cshtml.cs b/boot_portal/Pages/Setup.cshtml.cs index d8ab103..c8fe0f7 100644 --- a/boot_portal/Pages/Setup.cshtml.cs +++ b/boot_portal/Pages/Setup.cshtml.cs @@ -4,16 +4,19 @@ using boot_portal.Utils; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.Extensions.Hosting; namespace boot_portal.Pages; public sealed class SetupModel( PoolConfig poolConfig, NodeSetupState setupState, + IHostApplicationLifetime applicationLifetime, ILogger logger) : PageModel { private readonly PoolConfig _poolConfig = poolConfig; private readonly NodeSetupState _setupState = setupState; + private readonly IHostApplicationLifetime _applicationLifetime = applicationLifetime; private readonly ILogger _logger = logger; [BindProperty] @@ -24,6 +27,8 @@ public sealed class SetupModel( public bool RestartRequired => _setupState.RestartRequired; + public bool AutomaticRestart => RestartRequired && _poolConfig.RestartAfterSetup; + public string BitcoinNetwork => BitcoinScript.NormalizeNetwork(_poolConfig.BitcoinNetwork); public IActionResult OnGet() @@ -73,6 +78,14 @@ public IActionResult OnPost() _poolConfig.PoolPayoutScript = payoutAddress; _setupState.MarkSaved(payoutAddress); SavedAddress = payoutAddress; + if (_poolConfig.RestartAfterSetup) + { + HttpContext.Response.OnCompleted(() => + { + _applicationLifetime.StopApplication(); + return Task.CompletedTask; + }); + } } catch (Exception ex) { diff --git a/boot_portal/Services/BootProtocolStateService.cs b/boot_portal/Services/BootProtocolStateService.cs index 0e56eac..643ac21 100644 --- a/boot_portal/Services/BootProtocolStateService.cs +++ b/boot_portal/Services/BootProtocolStateService.cs @@ -4463,10 +4463,7 @@ public async Task TryImportCandidateStateAsync(BootStateBundle bundle, str return false; } - foreach (BootPayoutSnapshotContext context in bundle.SnapshotContexts) - { - UpsertSnapshotContextNoLock(context); - } + UpsertSnapshotContextsNoLock(bundle.SnapshotContexts); _state.OnDeckProofs = mergedCanonicalProofs; RebuildOnDeckListNoLock(); SetAcceptedParentBlockHashesNoLock(GetCanonicalParentBlockHashesForReserveNoLock(), _state.CurrentTipBlockHash); @@ -4524,7 +4521,7 @@ public async Task TryAdoptCurrentStateAsync(BootStateBundle bundle, string if (bundle.WinnersList.Count > _poolConfig.WinnersListSize || bundle.ShareProofs.Count > _poolConfig.SnapshotProofSlotCount || bundle.WorkSetProofs.Count > _poolConfig.WorkSetReserveLimit || - bundle.SnapshotContexts.Count > GetMaxSnapshotContextCountNoLock() || + bundle.SnapshotContexts.Count > GetMaxImportedSnapshotContextCount(_poolConfig) || (bundle.SnapshotFamilyMember?.BoundaryReserveProofs.Count ?? 0) > _poolConfig.WorkSetReserveLimit) { return false; @@ -4758,13 +4755,13 @@ public async Task TryAdoptCurrentStateAsync(BootStateBundle bundle, string { _state.ActiveSnapshotProofIds = validatedProofs.Select(proof => proof.ShareId).ToList(); } + _state.OnDeckProofs = SortAndTrimProofs( + validatedWorkSetProofs.Count > 0 ? validatedWorkSetProofs : _state.OnDeckProofs, + _poolConfig.WorkSetReserveLimit); foreach (BootPayoutSnapshotContext context in bundle.SnapshotContexts) { UpsertSnapshotContextNoLock(context); } - _state.OnDeckProofs = SortAndTrimProofs( - validatedWorkSetProofs.Count > 0 ? validatedWorkSetProofs : _state.OnDeckProofs, - _poolConfig.WorkSetReserveLimit); RebuildOnDeckListNoLock(); foreach (var proof in validatedProofs) { @@ -6295,6 +6292,23 @@ private void UpsertSnapshotContextNoLock(BootPayoutSnapshotContext context) PruneSnapshotContextsNoLock(); } + private void UpsertSnapshotContextsNoLock(IEnumerable contexts) + { + foreach (BootPayoutSnapshotContext context in contexts) + { + if (string.IsNullOrWhiteSpace(context.SnapshotId)) + { + continue; + } + + _state.SnapshotContexts.RemoveAll(existing => + string.Equals(existing.SnapshotId, context.SnapshotId, StringComparison.OrdinalIgnoreCase)); + _state.SnapshotContexts.Insert(0, CloneSnapshotContext(context)); + } + + PruneSnapshotContextsNoLock(); + } + private void PruneSnapshotContextsNoLock() { HashSet protectedIds = new(StringComparer.OrdinalIgnoreCase); @@ -6348,6 +6362,13 @@ private int GetMaxSnapshotContextCountNoLock() return Math.Max(_poolConfig.MaxStateBundleHistory, _poolConfig.WorkSetReserveMultiplier * 16); } + internal static int GetMaxImportedSnapshotContextCount(PoolConfig config) + { + // A full reserve can legitimately contain proofs created against many payout snapshots. + // Bound imports by the proof capacity, not by the much smaller local history-retention cap. + return config.SnapshotProofSlotCount + config.WorkSetReserveLimit + 2; + } + private int RepairMissingWorkSetSnapshotContextsNoLock(DateTime nowUtc) { HashSet existingContextIds = _state.SnapshotContexts diff --git a/boot_portal/Utils/NodeSetupPolicy.cs b/boot_portal/Utils/NodeSetupPolicy.cs index 6757355..1fb1653 100644 --- a/boot_portal/Utils/NodeSetupPolicy.cs +++ b/boot_portal/Utils/NodeSetupPolicy.cs @@ -6,6 +6,7 @@ public static bool IsAllowedSetupPath(PathString path) { return path.StartsWithSegments("/setup", StringComparison.OrdinalIgnoreCase) || path.Equals("/setup.css", StringComparison.OrdinalIgnoreCase) || + path.Equals("/setup.js", StringComparison.OrdinalIgnoreCase) || path.Equals("/health/live", StringComparison.OrdinalIgnoreCase) || path.Equals("/health/ready", StringComparison.OrdinalIgnoreCase); } diff --git a/boot_portal/wwwroot/setup.js b/boot_portal/wwwroot/setup.js new file mode 100644 index 0000000..6f421ee --- /dev/null +++ b/boot_portal/wwwroot/setup.js @@ -0,0 +1,27 @@ +(() => { + const status = document.getElementById("restart-status"); + if (!status) return; + + const startedAt = Date.now(); + + async function waitForReady() { + try { + const response = await fetch("/health/ready", { cache: "no-store" }); + const payload = await response.json(); + if (response.ok && payload.status === "ready") { + window.location.replace("/"); + return; + } + } catch { + // The expected connection drop confirms that the container is restarting. + } + + if (Date.now() - startedAt > 120000) { + status.textContent = "GridPool is taking longer than expected to restart. Restart the app from Umbrel, then reopen it."; + return; + } + window.setTimeout(waitForReady, 1000); + } + + window.setTimeout(waitForReady, 1000); +})();