diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e974409..3919c0d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -43,13 +43,13 @@ jobs: dotnet-version: 10.0.x - name: Restore app - run: dotnet restore boot_portal/boot_portal.csproj + run: dotnet restore boot_portal/boot_portal.csproj --locked-mode - name: Build app run: dotnet build boot_portal/boot_portal.csproj --configuration Release --no-restore - name: Restore tests - run: dotnet restore boot.tests/boot.tests.csproj + run: dotnet restore boot.tests/boot.tests.csproj --locked-mode - name: Build tests run: dotnet build boot.tests/boot.tests.csproj --configuration Release --no-restore @@ -57,12 +57,39 @@ jobs: - name: Run tests run: dotnet test boot.tests/boot.tests.csproj --configuration Release --no-build + - name: Dependency advisory report + run: dotnet list boot_portal/boot_portal.csproj package --vulnerable --include-transitive + + - name: Filesystem vulnerability scan + uses: aquasecurity/trivy-action@v0.36.0 + with: + scan-type: fs + scan-ref: . + severity: CRITICAL,HIGH + ignore-unfixed: true + exit-code: '1' + + secrets: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Scan repository history for secrets + run: >- + docker run --rm -v "$PWD:/repo" + ghcr.io/gitleaks/gitleaks@sha256:c00b6bd0aeb3071cbcb79009cb16a60dd9e0a7c60e2be9ab65d25e6bc8abbb7f + detect --source=/repo --redact --no-banner + docker: runs-on: ubuntu-latest needs: dotnet permissions: contents: read packages: write + id-token: write + attestations: write steps: - name: Checkout @@ -105,5 +132,7 @@ jobs: labels: ${{ steps.meta.outputs.labels }} build-args: | GRIDPOOL_RELEASE_VERSION=${{ github.ref_name }}+${{ github.sha }} + sbom: true + provenance: mode=max cache-from: type=gha cache-to: type=gha,mode=max diff --git a/.gitleaksignore b/.gitleaksignore new file mode 100644 index 0000000..5407cef --- /dev/null +++ b/.gitleaksignore @@ -0,0 +1,9 @@ +# Audited historical fixtures and retired prototype identities. +7ded520203d0c6859e1de70892651e12be8605c0:scripts/main-beta-status.sh:curl-auth-user:36 +428a84391ec4bc119cb32391ce933cfd318ca8e4:boot.tests/PoolConfigValidatorTests.cs:generic-api-key:142 +a636ac92f856e35fff3c7438d5d3acbfdf24b7d4:boot_portal/boot_portal_config.json:generic-api-key:3 +07f8da37b297264cbc349ecc695c705e59392f6a:boot_portal/boot_portal_config.json:generic-api-key:2 +07f8da37b297264cbc349ecc695c705e59392f6a:boot_portal/boot_portal_config.json:generic-api-key:3 +2df39a9959680f42455d178d661912af37a627ac:boot_portal/boot_portal_config.json:generic-api-key:3 +f8d788e4b771aea87f39a9b6ba9b19e86eb90dd3:boot_portal/boot_portal_config.json:generic-api-key:3 +f8d788e4b771aea87f39a9b6ba9b19e86eb90dd3:boot_portal/boot_portal_config.json:generic-api-key:2 diff --git a/Dockerfile b/Dockerfile index bce2fa8..f37278a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,23 +1,23 @@ ARG GRIDPOOL_RELEASE_VERSION=dev -FROM node:24-bookworm-slim AS dashboard-build +FROM node:24-bookworm-slim@sha256:ba849c60be29959425b8734d57b8b4b7d56f98edd9504c9af091d5281095a71e AS dashboard-build WORKDIR /src/boot_portal/ui COPY boot_portal/ui/package.json boot_portal/ui/package-lock.json ./ RUN npm ci COPY boot_portal/ui/ ./ RUN npm run build -FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +FROM mcr.microsoft.com/dotnet/sdk:10.0@sha256:4ea6fe75dd36706bb6d8c3c293d4c4315840f5d76ea28ac97def77e3ec487fa5 AS build WORKDIR /src COPY boot_portal/boot_portal.csproj boot_portal/ -RUN dotnet restore boot_portal/boot_portal.csproj +RUN dotnet restore boot_portal/boot_portal.csproj --locked-mode COPY . . COPY --from=dashboard-build /src/boot_portal/wwwroot/dashboard boot_portal/wwwroot/dashboard RUN dotnet publish boot_portal/boot_portal.csproj -c Release -o /app/publish /p:UseAppHost=false -FROM mcr.microsoft.com/dotnet/aspnet:10.0-noble AS runtime +FROM mcr.microsoft.com/dotnet/aspnet:10.0-noble@sha256:1fe86375600b62e6566b465da9553eef0621f13c67f40fe764cd8dbb1dee1497 AS runtime RUN apt-get update \ && apt-get install -y --no-install-recommends libsodium23 ca-certificates curl \ && rm -rf /var/lib/apt/lists/* \ diff --git a/boot.tests/BitcoinHashesTests.cs b/boot.tests/BitcoinHashesTests.cs new file mode 100644 index 0000000..f508c67 --- /dev/null +++ b/boot.tests/BitcoinHashesTests.cs @@ -0,0 +1,35 @@ +using boot_portal.Utils; + +namespace boot.tests; + +[TestClass] +public sealed class BitcoinHashesTests +{ + private const string RegtestGenesisHeader = + "01000000" + + "0000000000000000000000000000000000000000000000000000000000000000" + + "3ba3edfd7a7b12b27ac72c3e67768f617fc81bc3888a51323a9fb8aa4b1e5e4a" + + "dae5494d" + + "ffff7f20" + + "02000000"; + + [TestMethod] + public void RegtestPowLimitAcceptsRegtestGenesisAndMainnetRejectsIt() + { + BitcoinHeaderEvaluation regtest = BitcoinHashes.EvaluateHeader( + RegtestGenesisHeader, + DateTime.UtcNow, + BitcoinScript.Regtest); + BitcoinHeaderEvaluation mainnet = BitcoinHashes.EvaluateHeader( + RegtestGenesisHeader, + DateTime.UtcNow, + BitcoinScript.Mainnet); + + Assert.IsTrue(regtest.IsValid, regtest.RejectionReason); + Assert.AreEqual( + "0f9188f13cb7b2c71f2a335e3a4fc328bf5beb436012afca590b1a11466e2206", + regtest.BlockHash); + Assert.IsFalse(mainnet.IsValid); + StringAssert.Contains(mainnet.RejectionReason, "proof-of-work limit"); + } +} diff --git a/boot.tests/BitcoinRpcClientTests.cs b/boot.tests/BitcoinRpcClientTests.cs index 911f564..a655f4c 100644 --- a/boot.tests/BitcoinRpcClientTests.cs +++ b/boot.tests/BitcoinRpcClientTests.cs @@ -23,7 +23,7 @@ public async Task ClientParsesBlockchainAndZmqResponsesWithoutEmbeddingCredentia string result = method switch { "getblockchaininfo" => """ - {"blocks":100,"headers":100,"bestblockhash":"block-100","initialblockdownload":false,"verificationprogress":1.0} + {"chain":"regtest","blocks":100,"headers":100,"bestblockhash":"block-100","initialblockdownload":false,"verificationprogress":1.0} """, "getzmqnotifications" => """ [{"type":"pubhashblock","address":"tcp://0.0.0.0:28332"},{"type":"pubrawblock","address":"tcp://0.0.0.0:28333"}] @@ -46,6 +46,7 @@ public async Task ClientParsesBlockchainAndZmqResponsesWithoutEmbeddingCredentia Assert.AreEqual(100L, info.Blocks); Assert.AreEqual("block-100", info.BestBlockHash); + Assert.AreEqual("regtest", info.Chain); CollectionAssert.AreEquivalent( new[] { "pubhashblock", "pubrawblock" }, topics.Select(topic => topic.Topic).ToArray()); diff --git a/boot.tests/BitcoinRpcRecoveryPlannerTests.cs b/boot.tests/BitcoinRpcRecoveryPlannerTests.cs index db12be7..3fb8c13 100644 --- a/boot.tests/BitcoinRpcRecoveryPlannerTests.cs +++ b/boot.tests/BitcoinRpcRecoveryPlannerTests.cs @@ -42,12 +42,12 @@ public void SameHeightReplacementUsesReorganizationPath() 100, "new-block-100"); - CollectionAssert.AreEqual(new long[] { 100 }, plan.Heights.ToArray()); + CollectionAssert.AreEqual(new long[] { 99, 100 }, plan.Heights.ToArray()); Assert.IsTrue(plan.Reorganization); } [TestMethod] - public void LowerRpcHeightPausesUntilReplacementChainCatchesUp() + public void LowerRpcHeightReplaysReplacementTipAndParent() { BitcoinRpcRecoveryPlan plan = BitcoinRpcRecoveryPlanner.Build( 101, @@ -55,7 +55,7 @@ public void LowerRpcHeightPausesUntilReplacementChainCatchesUp() 100, "replacement-block-100"); - Assert.AreEqual(0, plan.Heights.Count); + CollectionAssert.AreEqual(new long[] { 99, 100 }, plan.Heights.ToArray()); Assert.IsTrue(plan.Reorganization); } diff --git a/boot.tests/BitcoinZmqNotificationTests.cs b/boot.tests/BitcoinZmqNotificationTests.cs index 461d680..6004672 100644 --- a/boot.tests/BitcoinZmqNotificationTests.cs +++ b/boot.tests/BitcoinZmqNotificationTests.cs @@ -128,7 +128,7 @@ public void AttachedNodeRequiresSynchronizedRpcAndRedactsErrors() BitcoinRpcUrl = "http://bitcoin:8332", BitcoinRpcLagGraceSeconds = 1 }); - DateTime nowUtc = DateTime.UtcNow; + DateTime nowUtc = DateTime.UtcNow.AddSeconds(2); health.RecordRpcFailure( "failed http://alice:secret@bitcoin:8332/wallet/private?token=secret", nowUtc.AddSeconds(-2)); @@ -188,4 +188,22 @@ public void BitcoinPeerTelemetryUsesStableProcessLocalVisualIds() Assert.AreNotEqual(7L, first); Assert.AreEqual(first, second); } + + [TestMethod] + public void RepeatedRpcFailuresCannotRenewMiningSafetyGracePeriod() + { + var health = new BitcoinNotificationHealth(new PoolConfig + { + BitcoinNotificationMode = BitcoinNotificationModes.AttachedNode, + BitcoinRpcUrl = "http://bitcoin:8332", + BitcoinRpcLagGraceSeconds = 1 + }); + DateTime afterGrace = DateTime.UtcNow.AddSeconds(2); + + health.RecordRpcFailure("warmup", afterGrace.AddMilliseconds(-500)); + health.RecordRpcFailure("still warming up", afterGrace); + + Assert.IsFalse(health.IsMiningSafe(afterGrace, out string reason)); + StringAssert.Contains(reason, "unreachable"); + } } diff --git a/boot.tests/PoolConfigValidatorTests.cs b/boot.tests/PoolConfigValidatorTests.cs index 3e8f0dd..42e69c2 100644 --- a/boot.tests/PoolConfigValidatorTests.cs +++ b/boot.tests/PoolConfigValidatorTests.cs @@ -7,13 +7,20 @@ namespace boot.tests; [TestClass] public sealed class PoolConfigValidatorTests { + private const string MainnetPayoutAddress = "bc1qd9m04z95mglaxd9e9accmhyjdlmkfmzjprkq4p"; + [TestMethod] - public void DefaultCoinbaseTagIsGridPool() + public void SetupUiMayStartWithoutPayoutButHeadlessModeCannot() { var config = new PoolConfig(); Assert.AreEqual("Grid Pool", config.CoinbaseTag); - CollectionAssert.AreEqual(Array.Empty(), PoolConfigValidator.Validate(config)); + Assert.IsFalse(PoolConfigValidator.Validate(config).Any(error => + error.Contains("pool_payout_script", StringComparison.OrdinalIgnoreCase))); + + config.EnableWebUi = false; + Assert.IsTrue(PoolConfigValidator.Validate(config).Any(error => + error.Contains("pool_payout_script", StringComparison.OrdinalIgnoreCase))); } [TestMethod] @@ -130,7 +137,9 @@ public void EmptyCoinbaseTagIsAllowed() { var config = new PoolConfig { - CoinbaseTag = string.Empty + CoinbaseTag = string.Empty, + PoolPayoutScript = MainnetPayoutAddress, + EnableAdminApi = false }; CollectionAssert.AreEqual(Array.Empty(), PoolConfigValidator.Validate(config)); @@ -207,6 +216,7 @@ public void Testnet4AcceptsTestnetPayoutAddressAndRejectsMainnetAddress() { BitcoinNetwork = BitcoinScript.Testnet4, PoolPayoutScript = testnetAddress, + EnableAdminApi = false }; CollectionAssert.AreEqual(Array.Empty(), PoolConfigValidator.Validate(validConfig)); @@ -222,7 +232,7 @@ public void UnsupportedBitcoinNetworkFailsValidation() { var config = new PoolConfig { - BitcoinNetwork = "regtest" + BitcoinNetwork = "signet" }; List errors = PoolConfigValidator.Validate(config); @@ -230,6 +240,58 @@ public void UnsupportedBitcoinNetworkFailsValidation() Assert.IsTrue(errors.Any(error => error.Contains("bitcoin_network", StringComparison.OrdinalIgnoreCase))); } + [TestMethod] + public void RegtestUsesBcrtAddressesAndRejectsMainnetAddresses() + { + byte[] script = Enumerable.Range(0, 22).Select(i => (byte)i).ToArray(); + script[0] = 0x00; + script[1] = 0x14; + string regtestAddress = BitcoinScript.ScriptToAddress(script, BitcoinScript.Regtest); + var config = new PoolConfig + { + BitcoinNetwork = BitcoinScript.Regtest, + PoolPayoutScript = regtestAddress, + EnableAdminApi = false + }; + + Assert.IsTrue(regtestAddress.StartsWith("bcrt1", StringComparison.OrdinalIgnoreCase)); + CollectionAssert.AreEqual(Array.Empty(), PoolConfigValidator.Validate(config)); + + config.PoolPayoutScript = "bc1qrwsx8fs0l6z7ugp5cvzy6lhss7jlyru3kg9s8y"; + Assert.IsTrue(PoolConfigValidator.Validate(config).Any(error => + error.Contains("pool_payout_script", StringComparison.OrdinalIgnoreCase))); + } + + [TestMethod] + public void EmptySnapshotBootstrapIsRestrictedToNonProductionRegtest() + { + var regtest = new PoolConfig + { + BitcoinNetwork = BitcoinScript.Regtest, + NodeMode = "development", + AllowEmptySnapshotBootstrap = true + }; + var mainnet = new PoolConfig + { + BitcoinNetwork = BitcoinScript.Mainnet, + NodeMode = "development", + AllowEmptySnapshotBootstrap = true + }; + var productionRegtest = new PoolConfig + { + BitcoinNetwork = BitcoinScript.Regtest, + NodeMode = "production", + AllowEmptySnapshotBootstrap = true + }; + + Assert.IsFalse(PoolConfigValidator.Validate(regtest).Any(error => + error.Contains("allow_empty_snapshot_bootstrap", StringComparison.OrdinalIgnoreCase))); + Assert.IsTrue(PoolConfigValidator.Validate(mainnet).Any(error => + error.Contains("allow_empty_snapshot_bootstrap", StringComparison.OrdinalIgnoreCase))); + Assert.IsTrue(PoolConfigValidator.Validate(productionRegtest).Any(error => + error.Contains("allow_empty_snapshot_bootstrap", StringComparison.OrdinalIgnoreCase))); + } + [TestMethod] public void BadRateLimitFailsValidation() { @@ -250,7 +312,9 @@ public void SovereignModeIsAcceptedForInstallerNodes() { NodeMode = "sovereign", PublicBaseUrl = "http://edge-node.local:5000", - DatumPublicHost = "edge-node.local" + DatumPublicHost = "edge-node.local", + PoolPayoutScript = MainnetPayoutAddress, + EnableAdminApi = false }; CollectionAssert.AreEqual(Array.Empty(), PoolConfigValidator.Validate(config)); @@ -301,7 +365,8 @@ public void ProductionAcceptsExplicitPublicEndpoints() PublicBaseUrl = "https://use1.gridlabs.science", DatumPublicHost = "datum-use1.gridlabs.science", EnableAdminApi = false, - TestingRoundResetMode = "none" + TestingRoundResetMode = "none", + PoolPayoutScript = MainnetPayoutAddress }; CollectionAssert.AreEqual(Array.Empty(), PoolConfigValidator.Validate(config)); @@ -335,7 +400,8 @@ public void ProductionAcceptsStrongAdminKeyWhenAdminApiIsEnabled() DatumPublicHost = "datum-use1.gridlabs.science", EnableAdminApi = true, AdminApiKey = new string('a', 32), - TestingRoundResetMode = "none" + TestingRoundResetMode = "none", + PoolPayoutScript = MainnetPayoutAddress }; CollectionAssert.AreEqual(Array.Empty(), PoolConfigValidator.Validate(config)); diff --git a/boot.tests/SecurityHardeningTests.cs b/boot.tests/SecurityHardeningTests.cs new file mode 100644 index 0000000..6ba03aa --- /dev/null +++ b/boot.tests/SecurityHardeningTests.cs @@ -0,0 +1,95 @@ +using System.Net; +using boot_portal.Controllers; +using boot_portal.Models; +using boot_portal.Services; +using boot_portal.Utils; + +namespace boot.tests; + +[TestClass] +public sealed class SecurityHardeningTests +{ + private const string MainnetPayoutAddress = "bc1qd9m04z95mglaxd9e9accmhyjdlmkfmzjprkq4p"; + + [TestMethod] + public void DatumResourceBoundsAreValidated() + { + var config = ValidConfig(); + config.DatumMaxConnections = 0; + config.DatumReadTimeoutSeconds = 0; + + List errors = PoolConfigValidator.Validate(config); + + Assert.IsTrue(errors.Any(error => error.Contains("datum_max_connections", StringComparison.OrdinalIgnoreCase))); + Assert.IsTrue(errors.Any(error => error.Contains("datum_read_timeout_seconds", StringComparison.OrdinalIgnoreCase))); + } + + [TestMethod] + public void AdminApiRequiresStrongKeyInSovereignMode() + { + var config = ValidConfig(); + config.EnableAdminApi = true; + config.AdminApiKey = "change-this-admin-key"; + + Assert.IsTrue(PoolConfigValidator.Validate(config).Any(error => + error.Contains("admin_api_key", StringComparison.OrdinalIgnoreCase))); + } + + [TestMethod] + public void StoredMinerLabelIsBoundedAndMarkupFree() + { + string normalized = BootShareVerifier.NormalizeUsernameForStorage( + "worker/../../" + new string('x', 256), + MainnetPayoutAddress); + + Assert.IsTrue(normalized.Length <= 128); + Assert.IsFalse(normalized.Contains('<')); + Assert.IsFalse(normalized.Contains('>')); + Assert.IsFalse(normalized.Contains('/')); + Assert.IsTrue(normalized.All(value => char.IsAsciiLetterOrDigit(value) || value is '.' or '_' or '-' or ':')); + } + + [DataTestMethod] + [DataRow("127.0.0.1")] + [DataRow("10.1.2.3")] + [DataRow("172.16.0.1")] + [DataRow("192.168.1.1")] + [DataRow("169.254.1.1")] + [DataRow("100.64.0.1")] + [DataRow("224.0.0.1")] + [DataRow("::1")] + [DataRow("fe80::1")] + [DataRow("fc00::1")] + public void ReachabilityProbeRejectsNonPublicDestinations(string value) + { + Assert.IsTrue(BootNetworkController.IsNonPublicAddress(IPAddress.Parse(value))); + } + + [TestMethod] + public void ReachabilityProbeAcceptsPublicUnicastDestination() + { + Assert.IsFalse(BootNetworkController.IsNonPublicAddress(IPAddress.Parse("1.1.1.1"))); + } + + [TestMethod] + public void PeerBundleFetchLimiterBoundsChangingStateIdsPerPeer() + { + var limiter = new PeerBundleFetchLimiter(2, TimeSpan.FromMinutes(1)); + DateTime start = new(2026, 8, 19, 0, 0, 0, DateTimeKind.Utc); + + Assert.IsTrue(limiter.TryAcquire("https://peer.example", start)); + Assert.IsTrue(limiter.TryAcquire("https://peer.example", start.AddSeconds(1))); + Assert.IsFalse(limiter.TryAcquire("https://peer.example", start.AddSeconds(2))); + Assert.IsTrue(limiter.TryAcquire("https://other.example", start.AddSeconds(2))); + Assert.IsTrue(limiter.TryAcquire("https://peer.example", start.AddMinutes(1).AddSeconds(1))); + } + + private static PoolConfig ValidConfig() + { + return new PoolConfig + { + PoolPayoutScript = MainnetPayoutAddress, + EnableAdminApi = false + }; + } +} diff --git a/boot.tests/ShareAttributionTests.cs b/boot.tests/ShareAttributionTests.cs index 0d17927..bccf308 100644 --- a/boot.tests/ShareAttributionTests.cs +++ b/boot.tests/ShareAttributionTests.cs @@ -397,6 +397,38 @@ public async Task LocalBitcoinConfirmationPromotesStoredCandidateExactlyOnceAsyn Assert.AreEqual(confirmed.CurrentStateId, duplicate.CurrentStateId); } + [TestMethod] + public async Task NotificationFirstExceptionRequiresExactLocallyValidatedActiveChainBlockAsync() + { + using var harness = TestHarness.Create(currentTipBlockHash: RecentBlockParentHash); + harness.Config.BitcoinNotificationMode = BitcoinNotificationModes.AttachedNode; + Assert.IsTrue(harness.StateService.ObserveLocalChainTipHeader( + RecentBlockHeaderHex, + "rpc-reconcile", + DateTime.UtcNow, + 945001)); + await harness.StateService.ObserveChainTipAsync(RecentBlockHash, "rpc-reconcile", 945001); + + var method = typeof(BootProtocolStateService).GetMethod( + "IsLocallyConfirmedCurrentBlockShareNoLock", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!; + bool exactMatch = (bool)method.Invoke(harness.StateService, [new BootShareHeaderEvaluationResult + { + IsValid = true, + IsBlock = true, + BlockHash = RecentBlockHash + }])!; + bool differentHash = (bool)method.Invoke(harness.StateService, [new BootShareHeaderEvaluationResult + { + IsValid = true, + IsBlock = true, + BlockHash = "0000000000000000000000000000000000000000000000000000000000000076" + }])!; + + Assert.IsTrue(exactMatch); + Assert.IsFalse(differentHash); + } + [TestMethod] public async Task PeerShareWithForgedMinerAddressIsAcceptedAndAttributedToSlotZeroAsync() { @@ -588,23 +620,23 @@ public async Task ProoflessBootstrapCannotInstallRemoteWinnersOrPaidLineageAsync [TestMethod] public async Task ProofBackedRemoteStateCannotRewriteUnverifiedPaidLineageAsync() { - using var remoteHarness = TestHarness.Create(currentTipBlockHash: OlderTipBlockHash); - ShareRecordingResult shareResult = await remoteHarness.StateService.SubmitShareAsync( - new RecordedShareSubmission - { - MinerAddress = AlternateAddress, - Username = string.Empty, - HeaderHex = SampleHeaderHex, - CoinbaseHex = SampleCoinbaseHex, - MerklePath = SampleMerklePath.ToList(), - PrevBlockHash = SamplePrevBlockHash, - Source = "datum" - }, - "datum-block"); - Assert.IsTrue(shareResult.Accepted, shareResult.RejectionReason); + byte[] header = Convert.FromHexString(SampleHeaderHex); + var proofSet = new List(); + for (uint nonce = 0; nonce < 16; nonce++) + { + BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(76, 4), nonce); + proofSet.Add(CreateValidatedProof( + Convert.ToHexString(header).ToLowerInvariant(), + SamplePrevBlockHash, + "seed-current")); + } + + using var remoteHarness = TestHarness.Create( + currentTipBlockHash: SamplePrevBlockHash, + onDeckProofs: proofSet); RoundRotationResult rotation = await remoteHarness.StateService.RotateToNextRoundAsync( - SamplePrevBlockHash, + OlderTipBlockHash, "test-rotation", manual: false, blockHeight: 945001, @@ -613,11 +645,11 @@ public async Task ProofBackedRemoteStateCannotRewriteUnverifiedPaidLineageAsync( Assert.IsTrue(remoteBundle.WorkSetProofs.Count > 0); using var localHarness = TestHarness.Create( - currentTipBlockHash: SamplePrevBlockHash, + currentTipBlockHash: OlderTipBlockHash, currentRoundNumber: remoteBundle.CurrentRoundNumber); bool adopted = await localHarness.StateService.TryAdoptCurrentStateAsync( remoteBundle, - SamplePrevBlockHash, + OlderTipBlockHash, 945001, "https://peer.example"); @@ -628,7 +660,7 @@ public async Task ProofBackedRemoteStateCannotRewriteUnverifiedPaidLineageAsync( } [TestMethod] - public async Task DatumShareOnFreshParentIsAcceptedAndLearnsParentWithoutTipAdvanceAsync() + public async Task DatumShareCannotTeachNodeAnUnvalidatedFreshParentAsync() { using var harness = TestHarness.Create(currentTipBlockHash: OlderTipBlockHash); @@ -643,17 +675,17 @@ public async Task DatumShareOnFreshParentIsAcceptedAndLearnsParentWithoutTipAdva Source = "datum" }, "datum-block"); - Assert.IsTrue(result.Accepted, result.RejectionReason); - Assert.AreEqual(SamplePrevBlockHash, result.AcceptedProof?.PrevBlockHash); + Assert.IsFalse(result.Accepted); + StringAssert.Contains(result.RejectionReason, "outside the active Bitcoin tip"); BootNetworkStatusDto status = harness.StateService.GetNetworkStatus(); Assert.AreEqual(OlderTipBlockHash, status.CurrentTipBlockHash); - Assert.AreEqual(1, harness.StateService.GetOnDeckList().Count); + Assert.AreEqual(0, harness.StateService.GetOnDeckList().Count); Thread.Sleep(1200); PoolState persisted = JsonSerializer.Deserialize(File.ReadAllText(harness.StatePath))!; CollectionAssert.Contains(persisted.AcceptedParentBlockHashes, OlderTipBlockHash); - CollectionAssert.Contains(persisted.AcceptedParentBlockHashes, SamplePrevBlockHash); + CollectionAssert.DoesNotContain(persisted.AcceptedParentBlockHashes, SamplePrevBlockHash); } [TestMethod] @@ -1363,7 +1395,7 @@ await harness.StateService.ObserveChainTipAsync( "test"); Assert.IsFalse(result.Accepted); - Assert.AreEqual("New previous-parent proof rejected after the local snapshot boundary.", result.RejectionReason); + StringAssert.Contains(result.RejectionReason, "outside the active Bitcoin tip"); Assert.AreEqual(0, harness.StateService.GetNetworkStatus().WorkSetCount); } @@ -1431,6 +1463,73 @@ public async Task V22SiblingBoundaryRaceConvergesWithoutBranchVotingAndV21DoesNo Assert.AreNotEqual(v21Alice.StateService.GetNetworkStatus().ActiveSnapshotId, v21Bob.StateService.GetNetworkStatus().ActiveSnapshotId); } + [TestMethod] + public async Task RegtestSiblingImportAcceptsProofRecordedAgainstExplicitEmptyBootstrapSnapshotAsync() + { + using var bootstrap = TestHarness.Create( + bitcoinNetwork: BitcoinScript.Regtest, + allowEmptySnapshotBootstrap: true); + var computeStateId = typeof(BootProtocolStateService).GetMethod( + "ComputeStateIdNoLock", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!; + string emptySnapshotId = (string)computeStateId.Invoke( + bootstrap.StateService, + [Array.Empty(), SamplePrevBlockHash])!; + var emptyContext = new BootPayoutSnapshotContext + { + SnapshotId = emptySnapshotId, + CurrentRoundNumber = 1, + LockedByBlockHash = SamplePrevBlockHash, + LockedByBlockHeight = 945000, + CreatedAtUtc = DateTime.UtcNow, + SupportFeeEnabled = false, + PayoutVariant = "fee-free:shared=299:snapshot=299:reserve=897", + ProofIds = [], + WinnersList = [], + FeeFreeWinnersList = [] + }; + + string emptyPlanCoinbase = BuildCoinbaseWithWinnerPrefix(SampleCoinbaseHex, positiveWinnerCount: 0); + string emptyPlanHeader = RewriteHeaderMerkleRoot(SampleHeaderHex, emptyPlanCoinbase); + BootShareProof firstProof = CreateValidatedProofForSnapshot( + emptyPlanHeader, + emptyPlanCoinbase, + SamplePrevBlockHash, + emptySnapshotId, + []); + string boundary = "0000000000000000000000000000000000000000000000000000000000b07601"; + + using var online = TestHarness.Create( + currentStateId: emptySnapshotId, + winnersList: [], + onDeckProofs: [firstProof], + snapshotContexts: [emptyContext], + activeSnapshotId: emptySnapshotId, + bitcoinNetwork: BitcoinScript.Regtest, + allowEmptySnapshotBootstrap: true); + using var rejoining = TestHarness.Create( + currentStateId: emptySnapshotId, + winnersList: [], + snapshotContexts: [emptyContext], + activeSnapshotId: emptySnapshotId, + bitcoinNetwork: BitcoinScript.Regtest, + allowEmptySnapshotBootstrap: true); + + await online.StateService.ObserveChainTipAsync(boundary, "local-bitcoin", 945001); + await rejoining.StateService.ObserveChainTipAsync(boundary, "local-bitcoin", 945001); + BootStateBundle sibling = online.StateService.GetStateBundle( + online.StateService.GetNetworkStatus().CurrentStateId)!; + + Assert.IsTrue(await rejoining.StateService.TryAdoptCurrentStateAsync( + sibling, + boundary, + 945001, + "online-regtest-peer")); + Assert.AreEqual( + online.StateService.GetNetworkStatus().ActiveSnapshotId, + rejoining.StateService.GetNetworkStatus().ActiveSnapshotId); + } + [TestMethod] public async Task V22HeightActivationUsesV21BelowHeightAndMsrAtAndAboveHeightAsync() { @@ -1588,6 +1687,36 @@ public async Task V22RepeatedOneBlockReorgRoundTripsSnapshotAndCandidateIdsWitho .ToArray()); } + [TestMethod] + public async Task V22TwoBlockReorgRollsBackRemovedFamiliesBeforeReplacementAsync() + { + BootShareProof proof = CreateValidatedProof(SampleHeaderHex, SamplePrevBlockHash, "seed-current"); + BootPayoutSnapshotContext predecessor = CreateSnapshotContext("seed-current", SampleExpectedWinners); + using var harness = TestHarness.Create( + sharedWinnerSlotCount: 1, + onDeckProofs: [proof], + snapshotContexts: [predecessor]); + string removedFirst = "0000000000000000000000000000000000000000000000000000000000a00601"; + string removedSecond = "0000000000000000000000000000000000000000000000000000000000a00602"; + string replacementFirst = "0000000000000000000000000000000000000000000000000000000000b00601"; + + await harness.StateService.ObserveChainTipAsync(removedFirst, "local-bitcoin", 945001); + BootNetworkStatusDto removed = await harness.StateService.ObserveChainTipAsync( + removedSecond, + "local-bitcoin", + 945002); + BootNetworkStatusDto replacement = await harness.StateService.ObserveChainTipAsync( + replacementFirst, + "local-bitcoin-reorg", + 945001); + + Assert.AreNotEqual(removed.ActiveSnapshotFamilyId, replacement.ActiveSnapshotFamilyId); + Assert.AreEqual(replacementFirst, replacement.CurrentTipBlockHash); + Assert.AreEqual(945001L, replacement.CurrentTipBlockHeight); + Assert.AreEqual(1, replacement.WorkSetCount); + Assert.AreEqual(2, replacement.CurrentRoundNumber); + } + [TestMethod] public async Task V22ReserveOnlySiblingAdditionDoesNotChangeActivePayoutSnapshotAsync() { @@ -3140,7 +3269,9 @@ public static TestHarness Create( bool seedUnknownTipHeight = false, bool seedUnknownTrustedTip = false, bool enablePeerTipStaleProtection = false, - int peerTipGraceSeconds = 3) + int peerTipGraceSeconds = 3, + string bitcoinNetwork = BitcoinScript.Mainnet, + bool allowEmptySnapshotBootstrap = false) { string? previousStatePath = Environment.GetEnvironmentVariable("BOOT_PORTAL_STATE_PATH"); string? previousHistoryPath = Environment.GetEnvironmentVariable("BOOT_PORTAL_HISTORY_PATH"); @@ -3154,10 +3285,14 @@ public static TestHarness Create( var config = new PoolConfig { BootNetworkId = "testnet", + BitcoinNetwork = bitcoinNetwork, BootProtocolVersion = protocolVersion ?? BootProtocolVersions.ConsensusVersion, V22ActivationBlockHeight = v22ActivationBlockHeight, WinnersListSize = sharedWinnerSlotCount ?? Math.Max(8, SampleExpectedWinners.Count), - PoolPayoutScript = SampleSlotZeroAddress, + PoolPayoutScript = BitcoinScript.ScriptToAddress( + BitcoinScript.AddressToScriptPubKey(SampleSlotZeroAddress), + bitcoinNetwork), + AllowEmptySnapshotBootstrap = allowEmptySnapshotBootstrap, GridLabsSupportFeeEnabled = supportFeeEnabled, WorkSetReserveMultiplier = workSetReserveMultiplier ?? 3, EnablePeerTipStaleProtection = enablePeerTipStaleProtection, @@ -3198,7 +3333,7 @@ public static TestHarness Create( var dashboardVisualization = new DashboardVisualizationJournalService(); var stateService = new BootProtocolStateService( config, - new BootShareVerifier(), + new BootShareVerifier(config), new NoOpHubContext(), NullLogger.Instance, dashboardVisualization: dashboardVisualization); diff --git a/boot.tests/boot.tests.csproj b/boot.tests/boot.tests.csproj index 8427d78..d974ca1 100644 --- a/boot.tests/boot.tests.csproj +++ b/boot.tests/boot.tests.csproj @@ -5,6 +5,8 @@ latest enable enable + true + true