diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c550f26..3e2efc2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,7 +12,7 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - os: [ubuntu-latest, macos-latest] + os: [ubuntu-latest, macos-latest, windows-latest] dotnet-version: ['9.0.x'] fail-fast: false diff --git a/native/linux-x64/libbitcoinkernel.so b/native/linux-x64/libbitcoinkernel.so index 3b6cb11..e877220 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 8e7369f..e2a995f 100755 Binary files a/native/osx-x64/libbitcoinkernel.dylib and b/native/osx-x64/libbitcoinkernel.dylib differ diff --git a/native/win-x64/bitcoinkernel.dll b/native/win-x64/bitcoinkernel.dll new file mode 100755 index 0000000..3f8d039 Binary files /dev/null and b/native/win-x64/bitcoinkernel.dll differ diff --git a/src/BitcoinKernel.Interop/BitcoinKernel.Interop.csproj b/src/BitcoinKernel.Interop/BitcoinKernel.Interop.csproj index ca9447e..9154e69 100644 --- a/src/BitcoinKernel.Interop/BitcoinKernel.Interop.csproj +++ b/src/BitcoinKernel.Interop/BitcoinKernel.Interop.csproj @@ -25,7 +25,13 @@ true runtimes/linux-x64/native - + + + PreserveNewest + true + runtimes/win-x64/native + + diff --git a/src/BitcoinKernel.Interop/NativeMethods.cs b/src/BitcoinKernel.Interop/NativeMethods.cs index 1b5c770..375b86f 100644 --- a/src/BitcoinKernel.Interop/NativeMethods.cs +++ b/src/BitcoinKernel.Interop/NativeMethods.cs @@ -488,6 +488,12 @@ public static extern int TransactionToBytes( [DllImport(LibName, CallingConvention = CallingConvention.Cdecl, EntryPoint = "btck_transaction_get_input_at")] public static extern IntPtr TransactionGetInputAt(IntPtr transaction, nuint index); + /// + /// Gets the nLockTime value of a transaction. + /// + [DllImport(LibName, CallingConvention = CallingConvention.Cdecl, EntryPoint = "btck_transaction_get_locktime")] + public static extern uint TransactionGetLocktime(IntPtr transaction); + /// /// Destroys a transaction. /// @@ -861,6 +867,12 @@ public static extern IntPtr TransactionSpentOutputsGetCoinAt( [DllImport(LibName, CallingConvention = CallingConvention.Cdecl, EntryPoint = "btck_transaction_input_get_out_point")] public static extern IntPtr TransactionInputGetOutPoint(IntPtr transaction_input); + /// + /// Gets the nSequence value from a transaction input. + /// + [DllImport(LibName, CallingConvention = CallingConvention.Cdecl, EntryPoint = "btck_transaction_input_get_sequence")] + public static extern uint TransactionInputGetSequence(IntPtr transaction_input); + /// /// Destroys a transaction input. /// diff --git a/src/BitcoinKernel/Primitives/Transaction.cs b/src/BitcoinKernel/Primitives/Transaction.cs index d560a41..c5d7d66 100644 --- a/src/BitcoinKernel/Primitives/Transaction.cs +++ b/src/BitcoinKernel/Primitives/Transaction.cs @@ -89,6 +89,11 @@ internal Transaction(IntPtr handle, bool ownsHandle = true) /// The number of outputs as an integer. public int OutputCount => (int)NativeMethods.TransactionCountOutputs(_handle); + /// + /// Gets the nLockTime value of this transaction. + /// + public uint LockTime => NativeMethods.TransactionGetLocktime(_handle); + /// /// Gets the transaction ID (txid) as bytes. /// @@ -118,7 +123,7 @@ public string GetTxidHex() /// Thrown when index is out of range. /// Thrown when input retrieval fails. - public IntPtr GetInputAt(int index) + public TxIn GetInputAt(int index) { ArgumentOutOfRangeException.ThrowIfNegative(index); ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(index, InputCount); @@ -127,7 +132,7 @@ public IntPtr GetInputAt(int index) if (inputPtr == IntPtr.Zero) throw new TransactionException($"Failed to get input at index {index}"); - return inputPtr; + return new TxIn(inputPtr, ownsHandle: false); } /// The TxOut at the specified index. diff --git a/src/BitcoinKernel/Primitives/TxIn.cs b/src/BitcoinKernel/Primitives/TxIn.cs new file mode 100644 index 0000000..d4645b4 --- /dev/null +++ b/src/BitcoinKernel/Primitives/TxIn.cs @@ -0,0 +1,69 @@ +using System; +using BitcoinKernel.Exceptions; +using BitcoinKernel.Interop; + +namespace BitcoinKernel.Primitives; + +/// +/// Managed wrapper for a Bitcoin transaction input. +/// +public sealed class TxIn : IDisposable +{ + private IntPtr _handle; + private bool _disposed; + private readonly bool _ownsHandle; + + internal TxIn(IntPtr handle, bool ownsHandle = true) + { + _handle = handle; + _ownsHandle = ownsHandle; + } + + /// + /// Gets the nSequence value of this input. + /// + public uint Sequence => NativeMethods.TransactionInputGetSequence(_handle); + + /// + /// Gets the out point of this input. The returned out point is not owned and + /// depends on the lifetime of the transaction. + /// + public IntPtr GetOutPoint() + { + IntPtr outPointPtr = NativeMethods.TransactionInputGetOutPoint(_handle); + if (outPointPtr == IntPtr.Zero) + throw new TransactionException("Failed to get out point from transaction input"); + + return outPointPtr; + } + + /// + /// Creates a copy of this transaction input. + /// + public TxIn Copy() + { + IntPtr copyHandle = NativeMethods.TransactionInputCopy(_handle); + if (copyHandle == IntPtr.Zero) + throw new TransactionException("Failed to copy transaction input"); + + return new TxIn(copyHandle, ownsHandle: true); + } + + internal IntPtr Handle => _handle; + + public void Dispose() + { + if (!_disposed && _handle != IntPtr.Zero && _ownsHandle) + { + NativeMethods.TransactionInputDestroy(_handle); + _handle = IntPtr.Zero; + _disposed = true; + } + GC.SuppressFinalize(this); + } + + ~TxIn() + { + Dispose(); + } +} diff --git a/tests/BitcoinKernel.Tests/TransactionTests.cs b/tests/BitcoinKernel.Tests/TransactionTests.cs new file mode 100644 index 0000000..f2e445b --- /dev/null +++ b/tests/BitcoinKernel.Tests/TransactionTests.cs @@ -0,0 +1,94 @@ +using BitcoinKernel.Primitives; +using Xunit; + +namespace BitcoinKernel.Tests; + +public class TransactionTests +{ + // Legacy P2PKH transaction — version 2, locktime 510826, sequence 0xFFFFFFFE + private const string LegacyTxHex = + "02000000013f7cebd65c27431a90bba7f796914fe8cc2ddfc3f2cbd6f7e5f2fc854534da95" + + "000000006b483045022100de1ac3bcdfb0332207c4a91f3832bd2c2915840165f876ab47c5" + + "f8996b971c3602201c6c053d750fadde599e6f5c4e1963df0f01fc0d97815e8157e3d59fe0" + + "9ca30d012103699b464d1d8bc9e47d4fb1cdaa89a1c5783d68363c4dbc4b524ed3d857148617" + + "feffffff02836d3c01000000001976a914fc25d6d5c94003bf5b0c7b640a248e2c637fcfb0" + + "88ac7ada8202000000001976a914fbed3d9b11183209a57999d54d59f67c019e756c88ac6acb0700"; + + // Segwit P2SH transaction — version 1, locktime 0, sequence 0xFFFFFFFF + private const string SegwitTxHex = + "01000000000101d9fd94d0ff0026d307c994d0003180a5f248146efb6371d040c5973f5f66d9" + + "df0400000017160014b31b31a6cb654cfab3c50567bcf124f48a0beaecffffffff012cbd1c00" + + "0000000017a914233b74bf0823fa58bbbd26dfc3bb4ae715547167870247304402206f60569c" + + "ac136c114a58aedd80f6fa1c51b49093e7af883e605c212bdafcd8d202200e91a55f408a021a" + + "d2631bc29a67bd6915b2d7e9ef0265627eabd7f7234455f6012103e7e802f50344303c76d12c" + + "089c8724c1b230e3b745693bbe16aad536293d15e300000000"; + + [Fact] + public void LockTime_LegacyTransaction_ReturnsCorrectValue() + { + using var tx = Transaction.FromHex(LegacyTxHex); + Assert.Equal(510826u, tx.LockTime); + } + + [Fact] + public void LockTime_SegwitTransaction_ReturnsZero() + { + using var tx = Transaction.FromHex(SegwitTxHex); + Assert.Equal(0u, tx.LockTime); + } + + [Fact] + public void GetInputAt_ReturnsTxIn() + { + using var tx = Transaction.FromHex(LegacyTxHex); + using var input = tx.GetInputAt(0); + Assert.NotNull(input); + Assert.IsType(input); + } + + [Fact] + public void GetInputAt_OutOfRange_Throws() + { + using var tx = Transaction.FromHex(LegacyTxHex); + Assert.Throws(() => tx.GetInputAt(-1)); + Assert.Throws(() => tx.GetInputAt(tx.InputCount)); + } + + [Fact] + public void TxIn_Sequence_LegacyTransaction_ReturnsCorrectValue() + { + using var tx = Transaction.FromHex(LegacyTxHex); + using var input = tx.GetInputAt(0); + // Sequence 0xFFFFFFFE = 4294967294 (RBF signalling) + Assert.Equal(0xFFFFFFFEu, input.Sequence); + } + + [Fact] + public void TxIn_Sequence_SegwitTransaction_ReturnsMaxValue() + { + using var tx = Transaction.FromHex(SegwitTxHex); + using var input = tx.GetInputAt(0); + // Sequence 0xFFFFFFFF = final (no RBF, no relative locktime) + Assert.Equal(0xFFFFFFFFu, input.Sequence); + } + + [Fact] + public void TxIn_GetOutPoint_ReturnsNonNullPointer() + { + using var tx = Transaction.FromHex(LegacyTxHex); + using var input = tx.GetInputAt(0); + var outPoint = input.GetOutPoint(); + Assert.NotEqual(IntPtr.Zero, outPoint); + } + + [Fact] + public void TxIn_Copy_IsIndependent() + { + using var tx = Transaction.FromHex(LegacyTxHex); + using var input = tx.GetInputAt(0); + using var copy = input.Copy(); + + Assert.NotNull(copy); + Assert.Equal(input.Sequence, copy.Sequence); + } +}