/ GUNRPG.Infrastructure / Ledger / RunLedger.cs
RunLedger.cs
  1  using System.Buffers.Binary;
  2  using System.Collections.Immutable;
  3  using System.Collections.ObjectModel;
  4  using System.Security.Cryptography;
  5  using GUNRPG.Gossip;
  6  using GUNRPG.Ledger.Indexing;
  7  using GUNRPG.Security;
  8  
  9  namespace GUNRPG.Ledger;
 10  
 11  public class RunLedger
 12  {
 13      private const int HashSize = SHA256.HashSizeInBytes;
 14      private const int GuidSize = 16;
 15      private const int Int64Size = 8;
 16  
 17      private static readonly ImmutableArray<byte> ZeroHash = ImmutableArray.Create(new byte[HashSize]);
 18  
 19      private readonly List<RunLedgerEntry> _entries = [];
 20      private readonly ReadOnlyCollection<RunLedgerEntry> _readOnlyEntries;
 21      private readonly MerkleSkipIndex _merkleSkipIndex;
 22  
 23      public RunLedger()
 24      {
 25          _readOnlyEntries = _entries.AsReadOnly();
 26          _merkleSkipIndex = new MerkleSkipIndex(GetEntryHashAt);
 27      }
 28  
 29      public IReadOnlyList<RunLedgerEntry> Entries => _readOnlyEntries;
 30  
 31      public RunLedgerEntry? Head => _entries.Count == 0 ? null : _entries[^1];
 32  
 33      public MerkleSkipIndex MerkleSkipIndex => _merkleSkipIndex;
 34  
 35      public RunLedgerEntry Append(RunValidationResult run)
 36      {
 37          return Append(run, DateTimeOffset.UtcNow);
 38      }
 39  
 40      [Obsolete("Use the TryAppendWithQuorum overload that accepts a SignatureVerifier.")]
 41      public bool TryAppendWithQuorum(
 42          RunValidationResult run,
 43          QuorumValidator quorumValidator,
 44          AuthoritySet authoritySet,
 45          QuorumPolicy quorumPolicy)
 46      {
 47          throw new NotSupportedException("Use the TryAppendWithQuorum overload that accepts a SignatureVerifier.");
 48      }
 49  
 50      public bool TryAppendWithQuorum(
 51          RunValidationResult run,
 52          SignatureVerifier signatureVerifier,
 53          QuorumValidator quorumValidator,
 54          AuthoritySet authoritySet,
 55          QuorumPolicy quorumPolicy)
 56      {
 57          return TryAppendWithQuorum(run, signatureVerifier, quorumValidator, authoritySet, quorumPolicy, DateTimeOffset.UtcNow);
 58      }
 59  
 60      internal RunLedgerEntry Append(RunValidationResult run, DateTimeOffset timestamp)
 61      {
 62          ArgumentNullException.ThrowIfNull(run);
 63  
 64          var index = (long)_entries.Count;
 65          var previousHash = _entries.Count == 0 ? ZeroHash : _entries[^1].EntryHash;
 66  
 67          var entryHash = ComputeEntryHash(index, previousHash, timestamp, run);
 68  
 69          var entry = new RunLedgerEntry(index, previousHash, entryHash, timestamp, run);
 70          _entries.Add(entry);
 71          _merkleSkipIndex.Append(entry);
 72          return entry;
 73      }
 74  
 75      internal bool TryAppendWithQuorum(
 76          RunValidationResult run,
 77          SignatureVerifier signatureVerifier,
 78          QuorumValidator quorumValidator,
 79          AuthoritySet authoritySet,
 80          QuorumPolicy quorumPolicy,
 81          DateTimeOffset timestamp)
 82      {
 83          ArgumentNullException.ThrowIfNull(run);
 84          ArgumentNullException.ThrowIfNull(signatureVerifier);
 85          ArgumentNullException.ThrowIfNull(quorumValidator);
 86          ArgumentNullException.ThrowIfNull(authoritySet);
 87          ArgumentNullException.ThrowIfNull(quorumPolicy);
 88  
 89          if (!signatureVerifier.Verify(run.Attestation, timestamp))
 90          {
 91              return false;
 92          }
 93  
 94          if (!quorumValidator.HasQuorum(run.Attestation, authoritySet, quorumPolicy))
 95          {
 96              return false;
 97          }
 98  
 99          Append(run, timestamp);
100          return true;
101      }
102  
103      public bool Verify()
104      {
105          return VerifyEntries(_entries);
106      }
107  
108      public LedgerHead GetHead()
109      {
110          if (_entries.Count == 0)
111          {
112              return new LedgerHead(-1, ZeroHash);
113          }
114  
115          var head = _entries[^1];
116          return new LedgerHead(head.Index, head.EntryHash);
117      }
118  
119      public IReadOnlyList<RunLedgerEntry> GetEntriesFrom(long fromIndex, int maxCount = int.MaxValue)
120      {
121          if (maxCount < 0)
122          {
123              throw new ArgumentOutOfRangeException(nameof(maxCount), "maxCount must be non-negative.");
124          }
125  
126          if (fromIndex < 0 || fromIndex >= _entries.Count)
127          {
128              return [];
129          }
130  
131          var count = Math.Min(_entries.Count - (int)fromIndex, maxCount);
132          return _entries.GetRange((int)fromIndex, count).AsReadOnly();
133      }
134  
135      public bool TryAppendEntry(RunLedgerEntry entry)
136      {
137          ArgumentNullException.ThrowIfNull(entry);
138  
139          if (entry.Run is null)
140          {
141              return false;
142          }
143  
144          var expectedIndex = (long)_entries.Count;
145          if (entry.Index != expectedIndex)
146          {
147              return false;
148          }
149  
150          if (entry.EntryHash.Length != HashSize || entry.PreviousHash.Length != HashSize)
151          {
152              return false;
153          }
154  
155          var expectedPreviousHash = _entries.Count == 0 ? ZeroHash : _entries[^1].EntryHash;
156          if (!CryptographicOperations.FixedTimeEquals(entry.PreviousHash.AsSpan(), expectedPreviousHash.AsSpan()))
157          {
158              return false;
159          }
160  
161          var recomputed = ComputeEntryHash(entry.Index, entry.PreviousHash, entry.Timestamp, entry.Run);
162          if (!CryptographicOperations.FixedTimeEquals(entry.EntryHash.AsSpan(), recomputed.AsSpan()))
163          {
164              return false;
165          }
166  
167          _entries.Add(entry);
168          _merkleSkipIndex.Append(entry);
169          return true;
170      }
171  
172      // Replaces an entry at the given index — internal for tamper-detection testing only.
173      internal void ReplaceEntryForTest(int index, RunLedgerEntry entry)
174      {
175          if (entry.Index != index)
176          {
177              throw new ArgumentException("Replacement entry index must match the target index.", nameof(entry));
178          }
179  
180          _entries[index] = entry;
181          _merkleSkipIndex.Update(entry);
182      }
183  
184      internal void ReplaceEntriesFrom(long divergenceIndex, IReadOnlyList<RunLedgerEntry> entries, int startIndex = 0)
185      {
186          ArgumentNullException.ThrowIfNull(entries);
187  
188          if (divergenceIndex < 0 || divergenceIndex > _entries.Count)
189          {
190              throw new ArgumentOutOfRangeException(nameof(divergenceIndex));
191          }
192  
193          if (startIndex < 0 || startIndex > entries.Count)
194          {
195              throw new ArgumentOutOfRangeException(nameof(startIndex));
196          }
197  
198          _entries.RemoveRange((int)divergenceIndex, _entries.Count - (int)divergenceIndex);
199          for (var i = startIndex; i < entries.Count; i++)
200          {
201              _entries.Add(entries[i]);
202          }
203  
204          _merkleSkipIndex.Rebuild(_entries);
205      }
206  
207      internal static bool VerifyEntries(IReadOnlyList<RunLedgerEntry> entries)
208      {
209          ArgumentNullException.ThrowIfNull(entries);
210  
211          for (var i = 0; i < entries.Count; i++)
212          {
213              var entry = entries[i];
214  
215              if (entry.Index != i)
216              {
217                  return false;
218              }
219  
220              if (entry.Run is null || entry.EntryHash.Length != HashSize || entry.PreviousHash.Length != HashSize)
221              {
222                  return false;
223              }
224  
225              var recomputed = ComputeEntryHash(entry.Index, entry.PreviousHash, entry.Timestamp, entry.Run);
226              if (!CryptographicOperations.FixedTimeEquals(entry.EntryHash.AsSpan(), recomputed.AsSpan()))
227              {
228                  return false;
229              }
230  
231              var expectedPreviousHash = i == 0 ? ZeroHash : entries[i - 1].EntryHash;
232              if (expectedPreviousHash.Length != HashSize)
233              {
234                  return false;
235              }
236  
237              if (!CryptographicOperations.FixedTimeEquals(entry.PreviousHash.AsSpan(), expectedPreviousHash.AsSpan()))
238              {
239                  return false;
240              }
241          }
242  
243          return true;
244      }
245  
246      internal static ImmutableArray<byte> ComputeEntryHash(
247          long index,
248          ImmutableArray<byte> previousHash,
249          DateTimeOffset timestamp,
250          RunValidationResult run)
251      {
252          // Fixed-width payload: int64 + 32 bytes + int64 + 3×16 bytes + 32 bytes = 144 bytes
253          var buffer = new byte[Int64Size + HashSize + Int64Size + GuidSize + GuidSize + GuidSize + HashSize];
254          var offset = 0;
255  
256          WriteInt64(index, buffer, ref offset);
257          WriteBytes(previousHash.AsSpan(), buffer, ref offset);
258          WriteInt64(timestamp.UtcTicks, buffer, ref offset);
259          WriteGuid(run.RunId, buffer, ref offset);
260          WriteGuid(run.PlayerId, buffer, ref offset);
261          WriteGuid(run.ServerId, buffer, ref offset);
262          WriteBytes(run.FinalStateHash, buffer, ref offset);
263  
264          return ImmutableArray.Create(SHA256.HashData(buffer));
265      }
266  
267      private static void WriteInt64(long value, Span<byte> destination, ref int offset)
268      {
269          BinaryPrimitives.WriteInt64BigEndian(destination[offset..], value);
270          offset += Int64Size;
271      }
272  
273      private static void WriteBytes(ReadOnlySpan<byte> value, Span<byte> destination, ref int offset)
274      {
275          value.CopyTo(destination[offset..]);
276          offset += value.Length;
277      }
278  
279      private static void WriteGuid(Guid value, Span<byte> destination, ref int offset)
280      {
281          if (!value.TryWriteBytes(destination[offset..], bigEndian: true, out var bytesWritten) || bytesWritten != GuidSize)
282          {
283              throw new InvalidOperationException("Failed to write a 16-byte big-endian Guid into the ledger entry hash buffer.");
284          }
285  
286          offset += bytesWritten;
287      }
288  
289      private ImmutableArray<byte>? GetEntryHashAt(long index)
290      {
291          if (index < 0 || index >= _entries.Count)
292          {
293              return null;
294          }
295  
296          return _entries[(int)index].EntryHash;
297      }
298  }