diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1520b30..00fa6e9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -127,7 +127,7 @@ jobs: path: native/${{ matrix.rid }} - name: Setup .NET - uses: actions/setup-dotnet@v5 + uses: actions/setup-dotnet@v6 with: dotnet-version: ${{ matrix.dotnet-version }} @@ -138,13 +138,13 @@ jobs: run: dotnet build --configuration Release --no-restore - name: Test with dotnet - run: dotnet test --no-restore --logger trx --results-directory "TestResults-${{ matrix.dotnet-version }}" + run: dotnet test --no-restore --report-xunit-trx --results-directory "TestResults-${{ matrix.os }}" - name: Upload dotnet test results uses: actions/upload-artifact@v7 with: - name: dotnet-results--${{ matrix.os }} - path: TestResults--${{ matrix.os }} + name: dotnet-results-${{ matrix.os }} + path: TestResults-${{ matrix.os }} if: ${{ always() }} lint: @@ -156,7 +156,7 @@ jobs: uses: actions/checkout@v7 - name: Setup .NET - uses: actions/setup-dotnet@v5.4.0 + uses: actions/setup-dotnet@v6 with: dotnet-version: '10.0.x' @@ -175,7 +175,7 @@ jobs: uses: actions/checkout@v7 - name: Setup .NET - uses: actions/setup-dotnet@v5.4.0 + uses: actions/setup-dotnet@v6 with: dotnet-version: '10.0.x' @@ -204,7 +204,7 @@ jobs: rid: linux-x64 env: - TEST_VERSION: '0.0.4-alpha.pr14.5' + TEST_VERSION: '0.0.4-alpha.pr17.1' TEST_REPO: 'stringintech/kernel-bindings-tests' TEST_DIR: '.conformance-tests' @@ -219,7 +219,7 @@ jobs: path: native/${{ matrix.rid }} - name: Setup .NET - uses: actions/setup-dotnet@v5.4.0 + uses: actions/setup-dotnet@v6 with: dotnet-version: '10.0.x' diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml index 5bb5b73..fd2ebb2 100644 --- a/.github/workflows/pr-validation.yml +++ b/.github/workflows/pr-validation.yml @@ -16,7 +16,7 @@ jobs: fetch-depth: 0 - name: Setup .NET - uses: actions/setup-dotnet@v5 + uses: actions/setup-dotnet@v6 with: dotnet-version: '10.0.x' diff --git a/CHANGELOG.md b/CHANGELOG.md index 27e2a51..ebcacfb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,21 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.32.0] + +### Added +- Bindings for the Bitcoin Core v32 kernel API; CI builds `libbitcoinkernel` from `v32.0rc2` (`bc795e60`) +- `TransactionInput.GetWitnessStack()` and the new `WitnessStack` type for reading witness items +- `TransactionInput.GetScriptSig()` returning the raw script sig bytes +- `ChainParameters.CreateSignet(byte[] challenge)` for signets with a custom challenge +- `ChainstateManagerOptions.SetDatabaseCacheBytes(ulong)` to size the total database cache (default 450 MiB) +- `MockTime.Set(...)` and `MockTime.Reset()` to override the kernel clock in tests + +### Changed +- Package version jumps from 0.3.0 to 0.32.0 so the minor version matches the targeted Bitcoin Core release +- Test project moved from xunit 2.9.3 to xunit.v3 4.0.1; `global.json` opts `dotnet test` into Microsoft.Testing.Platform. +- Replaced the VSTest-only `coverlet.collector` with `Microsoft.Testing.Extensions.CodeCoverage` + ## [0.3.0] - 2026-05-26 ### Breaking Changes diff --git a/README.md b/README.md index 97c4bb7..905d88e 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,9 @@ This library uses [libbitcoinkernel](https://github.com/bitcoin/bitcoin/tree/mas | Package | Version | Description | |---------|---------|-------------| -| **BitcoinKernel** | 0.3.0 | Managed wrappers and native bindings | +| **BitcoinKernel** | 0.32.0 | Managed wrappers and native bindings | + +The minor version tracks the Bitcoin Core release whose kernel API the package binds, so 0.32.x targets Bitcoin Core v32. ```bash dotnet add package BitcoinKernel diff --git a/global.json b/global.json new file mode 100644 index 0000000..3140116 --- /dev/null +++ b/global.json @@ -0,0 +1,5 @@ +{ + "test": { + "runner": "Microsoft.Testing.Platform" + } +} diff --git a/native/BITCOIN_COMMIT b/native/BITCOIN_COMMIT index 9f5a5e1..7039743 100644 --- a/native/BITCOIN_COMMIT +++ b/native/BITCOIN_COMMIT @@ -1 +1 @@ -ba48852f9e758df8e67ce5f51c0e3e2b68713ab4 +bc795e60dbb2c6e9c9556949731912429290626a diff --git a/native/linux-x64/libbitcoinkernel.so b/native/linux-x64/libbitcoinkernel.so index 2527e43..bd74e70 100755 Binary files a/native/linux-x64/libbitcoinkernel.so and b/native/linux-x64/libbitcoinkernel.so differ diff --git a/native/osx-x64/libbitcoinkernel.dylib b/native/osx-x64/libbitcoinkernel.dylib index 6071804..0498144 100755 Binary files a/native/osx-x64/libbitcoinkernel.dylib and b/native/osx-x64/libbitcoinkernel.dylib differ diff --git a/src/BitcoinKernel.Interop/NativeMethods.cs b/src/BitcoinKernel.Interop/NativeMethods.cs index d897c0c..ae57c8d 100644 --- a/src/BitcoinKernel.Interop/NativeMethods.cs +++ b/src/BitcoinKernel.Interop/NativeMethods.cs @@ -90,6 +90,14 @@ static NativeMethods() [DllImport(LibName, CallingConvention = CallingConvention.Cdecl, EntryPoint = "btck_chain_parameters_create")] public static extern IntPtr ChainParametersCreate(ChainType chain_type); + /// + /// Creates signet chain parameters with a user-provided challenge. + /// + [DllImport(LibName, CallingConvention = CallingConvention.Cdecl, EntryPoint = "btck_chain_parameters_create_signet")] + public static extern IntPtr ChainParametersCreateSignet( + byte[] challenge, + nuint challenge_len); + /// /// Destroys chain parameters. /// @@ -224,6 +232,16 @@ public static extern void ChainstateManagerOptionsUpdateChainstateDbInMemory( IntPtr options, int chainstate_db_in_memory); + /// + /// Sets the total database cache in bytes. The kernel splits it between the + /// block tree db, the chainstate db and the in-memory coins cache. + /// Returns 0 on success, non-zero if the value is rejected. + /// + [DllImport(LibName, CallingConvention = CallingConvention.Cdecl, EntryPoint = "btck_chainstate_manager_options_set_database_cache_bytes")] + public static extern int ChainstateManagerOptionsSetDatabaseCacheBytes( + IntPtr options, + ulong database_cache_bytes); + #endregion #region Block Operations @@ -923,6 +941,54 @@ public static extern IntPtr TransactionSpentOutputsGetCoinAt( [DllImport(LibName, CallingConvention = CallingConvention.Cdecl, EntryPoint = "btck_transaction_input_get_sequence")] public static extern uint TransactionInputGetSequence(IntPtr transaction_input); + /// + /// Gets the witness stack of a transaction input. + /// The returned stack is unowned and only valid for the lifetime of the input. + /// + [DllImport(LibName, CallingConvention = CallingConvention.Cdecl, EntryPoint = "btck_transaction_input_get_witness_stack")] + public static extern IntPtr TransactionInputGetWitnessStack(IntPtr transaction_input); + + /// + /// Serializes the script sig of a transaction input. + /// + [DllImport(LibName, CallingConvention = CallingConvention.Cdecl, EntryPoint = "btck_transaction_input_get_script_sig")] + public static extern int TransactionInputGetScriptSig( + IntPtr transaction_input, + WriteBytes writer, + IntPtr user_data); + + #endregion + + #region WitnessStack Operations + + /// + /// Gets the number of items in a witness stack. + /// + [DllImport(LibName, CallingConvention = CallingConvention.Cdecl, EntryPoint = "btck_witness_stack_count_items")] + public static extern nuint WitnessStackCountItems(IntPtr witness_stack); + + /// + /// Serializes the witness stack item at the specified index. + /// + [DllImport(LibName, CallingConvention = CallingConvention.Cdecl, EntryPoint = "btck_witness_stack_get_item_at")] + public static extern int WitnessStackGetItemAt( + IntPtr witness_stack, + nuint index, + WriteBytes writer, + IntPtr user_data); + + /// + /// Copies a witness stack. + /// + [DllImport(LibName, CallingConvention = CallingConvention.Cdecl, EntryPoint = "btck_witness_stack_copy")] + public static extern IntPtr WitnessStackCopy(IntPtr witness_stack); + + /// + /// Destroys a witness stack. + /// + [DllImport(LibName, CallingConvention = CallingConvention.Cdecl, EntryPoint = "btck_witness_stack_destroy")] + public static extern void WitnessStackDestroy(IntPtr witness_stack); + #endregion #region TransactionOutPoint Operations @@ -981,5 +1047,17 @@ public static extern IntPtr TransactionSpentOutputsGetCoinAt( #endregion + #region Testing + + /// + /// Overrides the kernel's clock with a fixed Unix timestamp, or restores the + /// system clock when timestamp is 0. Affects all kernel time reads globally. + /// Returns 0 on success, non-zero if timestamp is outside [0, 4294967295]. + /// + [DllImport(LibName, CallingConvention = CallingConvention.Cdecl, EntryPoint = "btck_set_mock_time")] + public static extern int SetMockTime(long timestamp); + + #endregion + } diff --git a/src/BitcoinKernel/BitcoinKernel.csproj b/src/BitcoinKernel/BitcoinKernel.csproj index 18808ea..8fb64d4 100644 --- a/src/BitcoinKernel/BitcoinKernel.csproj +++ b/src/BitcoinKernel/BitcoinKernel.csproj @@ -8,7 +8,7 @@ BitcoinKernel - 0.3.0 + 0.32.0 JanB84 .NET bindings and managed wrappers for libbitcoinkernel. Provides direct access to Bitcoin Core consensus and validation logic with automatic memory management. MIT diff --git a/src/BitcoinKernel/Chain/ChainParameters.cs b/src/BitcoinKernel/Chain/ChainParameters.cs index 52f66ff..cebd746 100644 --- a/src/BitcoinKernel/Chain/ChainParameters.cs +++ b/src/BitcoinKernel/Chain/ChainParameters.cs @@ -1,3 +1,4 @@ +using BitcoinKernel.Exceptions; using BitcoinKernel.Interop; using BitcoinKernel.Interop.Enums; @@ -16,6 +17,27 @@ public ChainParameters(ChainType chainType) throw new InvalidOperationException($"Failed to create chain parameters for chain type: {chainType}. The native library may not be loaded correctly."); } + private ChainParameters(IntPtr handle) + { + _handle = handle; + } + + /// + /// Creates signet chain parameters with a custom challenge script. + /// Blocks must satisfy the challenge to be valid. + /// + /// The raw signet challenge script bytes. + public static ChainParameters CreateSignet(byte[] challenge) + { + ArgumentNullException.ThrowIfNull(challenge); + + var handle = NativeMethods.ChainParametersCreateSignet(challenge, (nuint)challenge.Length); + if (handle == IntPtr.Zero) + throw new ChainParametersException(ChainType.SIGNET, "Failed to create signet chain parameters from the given challenge."); + + return new ChainParameters(handle); + } + internal IntPtr Handle { get diff --git a/src/BitcoinKernel/Chain/ChainStateManager.cs b/src/BitcoinKernel/Chain/ChainStateManager.cs index 2604c1a..d083d7a 100644 --- a/src/BitcoinKernel/Chain/ChainStateManager.cs +++ b/src/BitcoinKernel/Chain/ChainStateManager.cs @@ -310,6 +310,23 @@ public ChainstateManagerOptions SetChainstateDbInMemory(bool inMemory) return this; } + /// + /// Sets the total database cache in bytes. The kernel splits it between the + /// block tree db, the chainstate db and the in-memory coins cache. + /// Defaults to 450 MiB when not set. Values below 4 MiB are rejected, and so + /// are values above 1 GiB on 32-bit systems. + /// + public ChainstateManagerOptions SetDatabaseCacheBytes(ulong databaseCacheBytes) + { + ThrowIfDisposed(); + int result = NativeMethods.ChainstateManagerOptionsSetDatabaseCacheBytes(_handle, databaseCacheBytes); + + if (result != 0) + throw new KernelException($"Failed to set database cache to {databaseCacheBytes} bytes (error code: {result})"); + + return this; + } + private void ThrowIfDisposed() { if (_disposed) diff --git a/src/BitcoinKernel/MockTime.cs b/src/BitcoinKernel/MockTime.cs new file mode 100644 index 0000000..43bfaa0 --- /dev/null +++ b/src/BitcoinKernel/MockTime.cs @@ -0,0 +1,40 @@ +using BitcoinKernel.Exceptions; +using BitcoinKernel.Interop; + +namespace BitcoinKernel; + +/// +/// Overrides the clock the kernel reads, for tests. The override is process-wide +/// and affects every context, so gate its use (for example to regtest) yourself. +/// +public static class MockTime +{ + /// + /// Fixes the kernel clock at the given Unix timestamp in seconds. + /// + /// Between 1 and 4294967295, the largest block header timestamp. + public static void Set(long unixSeconds) + { + if (unixSeconds < 1 || unixSeconds > uint.MaxValue) + throw new ArgumentOutOfRangeException(nameof(unixSeconds), $"Mock time must be between 1 and {uint.MaxValue}"); + + Apply(unixSeconds); + } + + /// + /// Fixes the kernel clock at the given time, truncated to whole seconds. + /// + public static void Set(DateTimeOffset time) => Set(time.ToUnixTimeSeconds()); + + /// + /// Restores the system clock. + /// + public static void Reset() => Apply(0); + + private static void Apply(long unixSeconds) + { + int result = NativeMethods.SetMockTime(unixSeconds); + if (result != 0) + throw new KernelException($"Failed to set mock time to {unixSeconds} (error code: {result})"); + } +} diff --git a/src/BitcoinKernel/Primitives/TransactionInput.cs b/src/BitcoinKernel/Primitives/TransactionInput.cs index 2e5d929..604bc9e 100644 --- a/src/BitcoinKernel/Primitives/TransactionInput.cs +++ b/src/BitcoinKernel/Primitives/TransactionInput.cs @@ -55,6 +55,47 @@ public OutPoint GetOutPoint() return new OutPoint(outPointPtr, ownsHandle: false); } + /// + /// Gets the witness stack of this input. The returned stack is a + /// non-owning view whose lifetime is tied to this input. + /// + public WitnessStack GetWitnessStack() + { + ThrowIfDisposed(); + var witnessPtr = NativeMethods.TransactionInputGetWitnessStack(_handle); + if (witnessPtr == IntPtr.Zero) + throw new TransactionException("Failed to get witness stack from transaction input"); + + return new WitnessStack(witnessPtr, ownsHandle: false); + } + + /// + /// Gets the raw script sig bytes of this input. Empty for inputs that + /// carry no script sig, such as native segwit spends. + /// + public byte[] GetScriptSig() + { + ThrowIfDisposed(); + var bytes = new List(); + NativeMethods.WriteBytes writer = (data, len, _) => + { + // An empty script sig may arrive as a null pointer with len 0. + if (len == 0) + return 0; + + var buffer = new byte[len]; + System.Runtime.InteropServices.Marshal.Copy(data, buffer, 0, (int)len); + bytes.AddRange(buffer); + return 0; + }; + + int result = NativeMethods.TransactionInputGetScriptSig(_handle, writer, IntPtr.Zero); + if (result != 0) + throw new TransactionException("Failed to serialize script sig"); + + return bytes.ToArray(); + } + /// /// Creates an owned copy of this transaction input. /// diff --git a/src/BitcoinKernel/Primitives/WitnessStack.cs b/src/BitcoinKernel/Primitives/WitnessStack.cs new file mode 100644 index 0000000..f10254c --- /dev/null +++ b/src/BitcoinKernel/Primitives/WitnessStack.cs @@ -0,0 +1,126 @@ +using System.Runtime.InteropServices; +using BitcoinKernel.Exceptions; +using BitcoinKernel.Interop; + +namespace BitcoinKernel.Primitives; + +/// +/// Represents the witness stack of a transaction input. +/// +public sealed class WitnessStack : IDisposable +{ + private IntPtr _handle; + private bool _disposed; + private readonly bool _ownsHandle; + + internal WitnessStack(IntPtr handle, bool ownsHandle = true) + { + _handle = handle != IntPtr.Zero + ? handle + : throw new ArgumentException("Invalid witness stack handle", nameof(handle)); + _ownsHandle = ownsHandle; + } + + internal IntPtr Handle + { + get + { + ThrowIfDisposed(); + return _handle; + } + } + + /// + /// Gets the number of items on the witness stack. + /// + public int Count + { + get + { + ThrowIfDisposed(); + return (int)NativeMethods.WitnessStackCountItems(_handle); + } + } + + /// + /// Gets the raw bytes of the witness stack item at the specified index. + /// + public byte[] GetItem(int index) + { + ThrowIfDisposed(); + if (index < 0 || index >= Count) + throw new ArgumentOutOfRangeException(nameof(index)); + + var bytes = new List(); + NativeMethods.WriteBytes writer = (data, len, _) => + { + // Empty items (e.g. the CHECKMULTISIG dummy) arrive as a null pointer with len 0. + if (len == 0) + return 0; + + var buffer = new byte[len]; + Marshal.Copy(data, buffer, 0, (int)len); + bytes.AddRange(buffer); + return 0; + }; + + int result = NativeMethods.WitnessStackGetItemAt(_handle, (nuint)index, writer, IntPtr.Zero); + if (result != 0) + throw new TransactionException($"Failed to serialize witness stack item at index {index}"); + + return bytes.ToArray(); + } + + /// + /// Enumerates the raw bytes of every item on the witness stack, bottom to top. + /// + public IEnumerable EnumerateItems() + { + ThrowIfDisposed(); + + int count = Count; + for (int i = 0; i < count; i++) + { + yield return GetItem(i); + } + } + + /// + /// Creates an owned copy of this witness stack. + /// + public WitnessStack Copy() + { + ThrowIfDisposed(); + var copy = NativeMethods.WitnessStackCopy(_handle); + if (copy == IntPtr.Zero) + throw new TransactionException("Failed to copy witness stack"); + + return new WitnessStack(copy); + } + + private void ThrowIfDisposed() + { + if (_disposed) + throw new ObjectDisposedException(nameof(WitnessStack)); + } + + public void Dispose() + { + if (!_disposed) + { + if (_handle != IntPtr.Zero && _ownsHandle) + { + NativeMethods.WitnessStackDestroy(_handle); + _handle = IntPtr.Zero; + } + _disposed = true; + } + GC.SuppressFinalize(this); + } + + ~WitnessStack() + { + if (_ownsHandle) + Dispose(); + } +} diff --git a/tests/BitcoinKernel.Tests/BitcoinKernel.Tests.csproj b/tests/BitcoinKernel.Tests/BitcoinKernel.Tests.csproj index 92d1416..bcaa373 100644 --- a/tests/BitcoinKernel.Tests/BitcoinKernel.Tests.csproj +++ b/tests/BitcoinKernel.Tests/BitcoinKernel.Tests.csproj @@ -8,13 +8,15 @@ false true + + Exe - - - - + + + + diff --git a/tests/BitcoinKernel.Tests/ChainParametersTests.cs b/tests/BitcoinKernel.Tests/ChainParametersTests.cs new file mode 100644 index 0000000..fb005de --- /dev/null +++ b/tests/BitcoinKernel.Tests/ChainParametersTests.cs @@ -0,0 +1,39 @@ +using BitcoinKernel.Chain; +using Xunit; + +namespace BitcoinKernel.Tests; + +public class ChainParametersTests +{ + // 1-of-2 multisig challenge used by the default public signet. + private const string DefaultSignetChallengeHex = + "512103ad5e0edad18cb1f0fc0d28a3d4f1f3e445640337489abb10404f2d1e086be430210359ef5021964fe22d6f8e05b2463c9540ce96883fe3b278760f048f5189f2e6c452ae"; + + [Fact] + public void CreateSignet_WithChallenge_CanBackAContext() + { + using var chainParams = ChainParameters.CreateSignet(Convert.FromHexString(DefaultSignetChallengeHex)); + using var options = new KernelContextOptions().SetChainParams(chainParams); + using var context = new KernelContext(options); + + Assert.NotNull(context); + } + + // The kernel does not parse the challenge when building the parameters; it only + // matters once a block's signet solution is checked against it. + [Theory] + [InlineData("")] + [InlineData("ff")] + public void CreateSignet_WithEmptyOrNonScriptChallenge_Succeeds(string challengeHex) + { + using var chainParams = ChainParameters.CreateSignet(Convert.FromHexString(challengeHex)); + + Assert.NotNull(chainParams); + } + + [Fact] + public void CreateSignet_WithNullChallenge_Throws() + { + Assert.Throws(() => ChainParameters.CreateSignet(null!)); + } +} diff --git a/tests/BitcoinKernel.Tests/ChainstateManagerOptionsTests.cs b/tests/BitcoinKernel.Tests/ChainstateManagerOptionsTests.cs new file mode 100644 index 0000000..53c55a3 --- /dev/null +++ b/tests/BitcoinKernel.Tests/ChainstateManagerOptionsTests.cs @@ -0,0 +1,72 @@ +using BitcoinKernel.Chain; +using BitcoinKernel.Exceptions; +using BitcoinKernel.Interop.Enums; +using Xunit; + +namespace BitcoinKernel.Tests; + +public class ChainstateManagerOptionsTests : IDisposable +{ + private const ulong MiB = 1024 * 1024; + + private readonly ChainParameters _chainParams; + private readonly KernelContextOptions _contextOptions; + private readonly KernelContext _context; + private readonly string _dataDir; + + public ChainstateManagerOptionsTests() + { + _chainParams = new ChainParameters(ChainType.REGTEST); + _contextOptions = new KernelContextOptions().SetChainParams(_chainParams); + _context = new KernelContext(_contextOptions); + _dataDir = Path.Combine(Path.GetTempPath(), $"btck_options_test_{Guid.NewGuid()}"); + } + + public void Dispose() + { + _context.Dispose(); + _contextOptions.Dispose(); + _chainParams.Dispose(); + if (Directory.Exists(_dataDir)) + Directory.Delete(_dataDir, recursive: true); + } + + private ChainstateManagerOptions CreateOptions() => + new(_context, _dataDir, Path.Combine(_dataDir, "blocks")); + + [Fact] + public void SetDatabaseCacheBytes_ValidSize_ReturnsSameInstance() + { + using var options = CreateOptions(); + + Assert.Same(options, options.SetDatabaseCacheBytes(64 * MiB)); + } + + [Fact] + public void SetDatabaseCacheBytes_AtMinimum_IsAccepted() + { + using var options = CreateOptions(); + + options.SetDatabaseCacheBytes(4 * MiB); + } + + [Fact] + public void SetDatabaseCacheBytes_OptionsCreateChainstateManager() + { + using var options = CreateOptions() + .SetDatabaseCacheBytes(16 * MiB) + .SetBlockTreeDbInMemory(true) + .SetChainstateDbInMemory(true); + using var manager = new ChainstateManager(_context, _chainParams, options); + + Assert.Equal(0, manager.GetActiveChain().Height); + } + + [Fact] + public void SetDatabaseCacheBytes_BelowMinimum_Throws() + { + using var options = CreateOptions(); + + Assert.Throws(() => options.SetDatabaseCacheBytes(4 * MiB - 1)); + } +} diff --git a/tests/BitcoinKernel.Tests/MockTimeTests.cs b/tests/BitcoinKernel.Tests/MockTimeTests.cs new file mode 100644 index 0000000..ef81707 --- /dev/null +++ b/tests/BitcoinKernel.Tests/MockTimeTests.cs @@ -0,0 +1,84 @@ +using BitcoinKernel.Chain; +using BitcoinKernel.Interop.Enums; +using BitcoinKernel.Primitives; +using Xunit; + +namespace BitcoinKernel.Tests; + +/// +/// Mock time is process-wide, so these tests must not overlap with any other +/// test that validates blocks or headers. +/// +[CollectionDefinition(nameof(MockTimeCollection), DisableParallelization = true)] +public class MockTimeCollection; + +[Collection(nameof(MockTimeCollection))] +public class MockTimeTests +{ + // Before the 2024-04-27 timestamp of the first test block. + private const long BeforeTestBlocks = 1_700_000_000; + + [Fact] + public void SetAndReset_ValidTimestamp_Succeeds() + { + try + { + MockTime.Set(BeforeTestBlocks); + MockTime.Set(DateTimeOffset.FromUnixTimeSeconds(uint.MaxValue)); + } + finally + { + MockTime.Reset(); + } + } + + [Theory] + [InlineData(0L)] + [InlineData(-1L)] + [InlineData((long)uint.MaxValue + 1)] + public void Set_OutOfRange_Throws(long unixSeconds) + { + Assert.Throws(() => MockTime.Set(unixSeconds)); + } + + [Fact] + public void Set_BeforeHeaderTimestamp_RejectsHeaderAsTimeFuture() + { + var dataDir = Path.Combine(Path.GetTempPath(), $"test_mocktime_{Guid.NewGuid()}"); + try + { + using var chainParams = new ChainParameters(ChainType.REGTEST); + using var contextOptions = new KernelContextOptions().SetChainParams(chainParams); + using var context = new KernelContext(contextOptions); + using var options = new ChainstateManagerOptions(context, dataDir, Path.Combine(dataDir, "blocks")); + using var manager = new ChainstateManager(context, chainParams, options); + using var header = BlockHeader.FromBytes(ReadFirstBlock().Take(80).ToArray()); + + MockTime.Set(BeforeTestBlocks); + Assert.False(manager.ProcessBlockHeader(header, out var rejected)); + using (rejected) + { + Assert.Equal(BlockValidationResult.TIME_FUTURE, rejected.ValidationResult); + } + + MockTime.Reset(); + Assert.True(manager.ProcessBlockHeader(header, out var accepted)); + accepted.Dispose(); + } + finally + { + MockTime.Reset(); + if (Directory.Exists(dataDir)) + Directory.Delete(dataDir, recursive: true); + } + } + + private static byte[] ReadFirstBlock() + { + var testAssemblyDir = Path.GetDirectoryName(typeof(MockTimeTests).Assembly.Location); + var projectDir = Path.GetDirectoryName(Path.GetDirectoryName(Path.GetDirectoryName(testAssemblyDir))); + var blockDataFile = Path.Combine(projectDir!, "TestData", "block_data.txt"); + + return Convert.FromHexString(File.ReadLines(blockDataFile).First(l => !string.IsNullOrWhiteSpace(l)).Trim()); + } +} diff --git a/tests/BitcoinKernel.Tests/TransactionInputTests.cs b/tests/BitcoinKernel.Tests/TransactionInputTests.cs new file mode 100644 index 0000000..183fa12 --- /dev/null +++ b/tests/BitcoinKernel.Tests/TransactionInputTests.cs @@ -0,0 +1,137 @@ +using BitcoinKernel.Primitives; +using Xunit; + +namespace BitcoinKernel.Tests; + +public class TransactionInputTests +{ + // P2PKH spend: 107-byte script sig, no witness. + private const string LegacyTxHex = + "02000000013f7cebd65c27431a90bba7f796914fe8cc2ddfc3f2cbd6f7e5f2fc854534da95000000006b483045022100de1ac3bcdfb0332207c4a91f3832bd2c2915840165f876ab47c5f8996b971c3602201c6c053d750fadde599e6f5c4e1963df0f01fc0d97815e8157e3d59fe09ca30d012103699b464d1d8bc9e47d4fb1cdaa89a1c5783d68363c4dbc4b524ed3d857148617feffffff02836d3c01000000001976a914fc25d6d5c94003bf5b0c7b640a248e2c637fcfb088ac7ada8202000000001976a914fbed3d9b11183209a57999d54d59f67c019e756c88ac6acb0700"; + + // P2WSH 2-of-3 multisig spend: empty script sig, witness of + // [dummy, sig, sig, witness script]. + private const string SegwitTxHex = + "010000000001011f97548fbbe7a0db7588a66e18d803d0089315aa7d4cc28360b6ec50ef36718a0100000000ffffffff02df1776000000000017a9146c002a686959067f4866b8fb493ad7970290ab728757d29f0000000000220020701a8d401c84fb13e6baf169d59684e17abd9fa216c8cc5b9fc63d622ff8c58d04004730440220565d170eed95ff95027a69b313758450ba84a01224e1f7f130dda46e94d13f8602207bdd20e307f062594022f12ed5017bbf4a055a06aea91c10110a0e3bb23117fc014730440220647d2dc5b15f60bc37dc42618a370b2a1490293f9e5c8464f53ec4fe1dfe067302203598773895b4b16d37485cbe21b337f4e4b650739880098c592553add7dd4355016952210375e00eb72e29da82b89367947f29ef34afb75e8654f6ea368e0acdfd92976b7c2103a1b26313f430c4b15bb1fdce663207659d8cac749a0e53d70eff01874496feff2103c96d495bfdd5ba4145e3e046fee45e84a8a48ad05bd8dbb395c011a32cf9f88053ae00000000"; + + // P2SH-wrapped P2WPKH spend: script sig pushes the witness program, witness + // holds [sig, pubkey]. + private const string P2shSegwitTxHex = + "01000000000101d9fd94d0ff0026d307c994d0003180a5f248146efb6371d040c5973f5f66d9df0400000017160014b31b31a6cb654cfab3c50567bcf124f48a0beaecffffffff012cbd1c000000000017a914233b74bf0823fa58bbbd26dfc3bb4ae715547167870247304402206f60569cac136c114a58aedd80f6fa1c51b49093e7af883e605c212bdafcd8d202200e91a55f408a021ad2631bc29a67bd6915b2d7e9ef0265627eabd7f7234455f6012103e7e802f50344303c76d12c089c8724c1b230e3b745693bbe16aad536293d15e300000000"; + + [Fact] + public void GetScriptSig_LegacyInput_ReturnsScriptSig() + { + using var tx = Transaction.FromHex(LegacyTxHex); + using var input = tx.GetInputAt(0); + + var scriptSig = input.GetScriptSig(); + + Assert.Equal(0x6b, scriptSig.Length); + Assert.Equal( + "483045022100de1ac3bcdfb0332207c4a91f3832bd2c2915840165f876ab47c5f8996b971c3602201c6c053d750fadde599e6f5c4e1963df0f01fc0d97815e8157e3d59fe09ca30d012103699b464d1d8bc9e47d4fb1cdaa89a1c5783d68363c4dbc4b524ed3d857148617", + Convert.ToHexString(scriptSig).ToLowerInvariant()); + } + + [Fact] + public void GetScriptSig_SegwitInput_ReturnsEmpty() + { + using var tx = Transaction.FromHex(SegwitTxHex); + using var input = tx.GetInputAt(0); + + Assert.Empty(input.GetScriptSig()); + } + + [Fact] + public void GetWitnessStack_LegacyInput_IsEmpty() + { + using var tx = Transaction.FromHex(LegacyTxHex); + using var input = tx.GetInputAt(0); + using var witness = input.GetWitnessStack(); + + Assert.Equal(0, witness.Count); + Assert.Empty(witness.EnumerateItems()); + } + + [Fact] + public void GetWitnessStack_SegwitInput_ReturnsItems() + { + using var tx = Transaction.FromHex(SegwitTxHex); + using var input = tx.GetInputAt(0); + using var witness = input.GetWitnessStack(); + + Assert.Equal(4, witness.Count); + + var items = witness.EnumerateItems().ToList(); + Assert.Empty(items[0]); + Assert.Equal(0x47, items[1].Length); + Assert.Equal(0x30, items[1][0]); + Assert.Equal(0x47, items[2].Length); + Assert.Equal(0x69, items[3].Length); + Assert.Equal(0xae, items[3][^1]); // OP_CHECKMULTISIG + } + + [Fact] + public void P2shSegwitInput_HasScriptSigAndWitness() + { + using var tx = Transaction.FromHex(P2shSegwitTxHex); + using var input = tx.GetInputAt(0); + using var witness = input.GetWitnessStack(); + + Assert.Equal( + "160014b31b31a6cb654cfab3c50567bcf124f48a0beaec", + Convert.ToHexString(input.GetScriptSig()).ToLowerInvariant()); + + Assert.Equal(2, witness.Count); + Assert.Equal(0x47, witness.GetItem(0).Length); + Assert.Equal( + "03e7e802f50344303c76d12c089c8724c1b230e3b745693bbe16aad536293d15e3", + Convert.ToHexString(witness.GetItem(1)).ToLowerInvariant()); + } + + [Fact] + public void WitnessStack_AfterDispose_Throws() + { + using var tx = Transaction.FromHex(SegwitTxHex); + using var input = tx.GetInputAt(0); + var witness = input.GetWitnessStack(); + + witness.Dispose(); + witness.Dispose(); // second call is a no-op + + Assert.Throws(() => witness.Count); + Assert.Throws(() => witness.GetItem(0)); + Assert.Throws(() => witness.Copy()); + } + + [Fact] + public void GetWitnessStack_GetItem_OutOfRange_Throws() + { + using var tx = Transaction.FromHex(SegwitTxHex); + using var input = tx.GetInputAt(0); + using var witness = input.GetWitnessStack(); + + Assert.Throws(() => witness.GetItem(-1)); + Assert.Throws(() => witness.GetItem(4)); + } + + [Fact] + public void WitnessStack_Copy_OutlivesTransaction() + { + WitnessStack copy; + byte[] lastItem; + using (var tx = Transaction.FromHex(SegwitTxHex)) + using (var input = tx.GetInputAt(0)) + using (var witness = input.GetWitnessStack()) + { + copy = witness.Copy(); + lastItem = witness.GetItem(3); + } + + using (copy) + { + Assert.Equal(4, copy.Count); + Assert.Equal(lastItem, copy.GetItem(3)); + } + } +}