diff --git a/Casper.Network.SDK.Test/CESJsonSerializerTest.cs b/Casper.Network.SDK.Test/CESJsonSerializerTest.cs new file mode 100644 index 0000000..7268ea3 --- /dev/null +++ b/Casper.Network.SDK.Test/CESJsonSerializerTest.cs @@ -0,0 +1,284 @@ +using System.Numerics; +using System.Text.Json; +using Casper.Network.SDK.CES; +using Casper.Network.SDK.Types; +using NUnit.Framework; +using Org.BouncyCastle.Utilities.Encoders; + +namespace NetCasperTest +{ + /// + /// Verifies that and round-trip + /// correctly through System.Text.Json serialization/deserialization using the + /// [JsonPropertyName] and [JsonConstructor] annotations added to those classes. + /// + public class CESJsonSerializerTest + { + // ── shared fixtures (same as CESParserTest) ─────────────────────────── + // + // Schema: one event "Transfer" with fields amount (U512) and sender (String) + private static readonly string SchemaHex = string.Concat( + "01000000", + "08000000", + "5472616e73666572", + "02000000", + "06000000", "616d6f756e74", "08", + "06000000", "73656e646572", "0a" + ); + + // event_Transfer(amount=100, sender="Alice") + private static readonly string EventHex = string.Concat( + "0e000000", + "6576656e745f5472616e73666572", + "01", "64", + "05000000", "416c696365" + ); + + private const string ContractHash = "hash-aabbccddaabbccddaabbccddaabbccddaabbccddaabbccddaabbccddaabbccdd"; + private const string ContractPkgHash = "contract-package-hash-1122334411223344112233441122334411223344112233441122334411223344"; + + // ── helpers ─────────────────────────────────────────────────────────── + + private static CESContractSchema BuildAnnotatedSchema() => + new CESContractSchema(CESContractSchema.ParseSchema(Hex.Decode(SchemaHex)).Events) + { + ContractHash = ContractHash, + ContractPackageHash = ContractPkgHash, + }; + + private static CESEvent BuildAnnotatedEvent() + { + var schema = BuildAnnotatedSchema(); + var evt = CESEvent.ParseEvent(Hex.Decode(EventHex), schema); + return new CESEvent(evt.Name, evt.Fields) + { + ContractHash = ContractHash, + ContractPackageHash = ContractPkgHash, + TransformId = 3, + EventId = "42", + }; + } + + // ── CESEventSchemaField ─────────────────────────────────────────────── + + [Test] + public void SchemaField_RoundTrip_PreservesNameAndCLType() + { + var schema = CESContractSchema.ParseSchema(Hex.Decode(SchemaHex)); + var field = schema.Events["Transfer"].Fields[0]; + + var json = JsonSerializer.Serialize(field); + var restored = JsonSerializer.Deserialize(json); + + Assert.AreEqual(field.Name, restored.Name); + Assert.AreEqual(field.CLTypeInfo.Type, restored.CLTypeInfo.Type); + } + + [Test] + public void SchemaField_Serializes_SnakeCasePropertyNames() + { + var schema = CESContractSchema.ParseSchema(Hex.Decode(SchemaHex)); + var field = schema.Events["Transfer"].Fields[0]; + + var json = JsonSerializer.Serialize(field); + + Assert.IsTrue(json.Contains("\"name\"")); + Assert.IsTrue(json.Contains("\"cl_type\"")); + } + + // ── CESEventSchema ──────────────────────────────────────────────────── + + [Test] + public void EventSchema_RoundTrip_PreservesEventNameAndFields() + { + var schema = CESContractSchema.ParseSchema(Hex.Decode(SchemaHex)); + var eventSchema = schema.Events["Transfer"]; + + var json = JsonSerializer.Serialize(eventSchema); + var restored = JsonSerializer.Deserialize(json); + + Assert.AreEqual("Transfer", restored.EventName); + Assert.AreEqual(2, restored.Fields.Count); + Assert.AreEqual("amount", restored.Fields[0].Name); + Assert.AreEqual(CLType.U512, restored.Fields[0].CLTypeInfo.Type); + Assert.AreEqual("sender", restored.Fields[1].Name); + Assert.AreEqual(CLType.String, restored.Fields[1].CLTypeInfo.Type); + } + + [Test] + public void EventSchema_Serializes_SnakeCasePropertyNames() + { + var schema = CESContractSchema.ParseSchema(Hex.Decode(SchemaHex)); + var eventSchema = schema.Events["Transfer"]; + + var json = JsonSerializer.Serialize(eventSchema); + + Assert.IsTrue(json.Contains("\"event_name\"")); + Assert.IsTrue(json.Contains("\"fields\"")); + } + + // ── CESContractSchema ───────────────────────────────────────────────── + + [Test] + public void ContractSchema_RoundTrip_PreservesEvents() + { + var schema = BuildAnnotatedSchema(); + + var json = JsonSerializer.Serialize(schema); + var restored = JsonSerializer.Deserialize(json); + + Assert.IsNotNull(restored); + Assert.IsTrue(restored.TryGetEventSchema("Transfer", out var evt)); + Assert.AreEqual("Transfer", evt.EventName); + Assert.AreEqual(2, evt.Fields.Count); + } + + [Test] + public void ContractSchema_RoundTrip_PreservesContractHash() + { + var schema = BuildAnnotatedSchema(); + + var json = JsonSerializer.Serialize(schema); + var restored = JsonSerializer.Deserialize(json); + + Assert.AreEqual(ContractHash, restored.ContractHash); + } + + [Test] + public void ContractSchema_RoundTrip_PreservesContractPackageHash() + { + var schema = BuildAnnotatedSchema(); + + var json = JsonSerializer.Serialize(schema); + var restored = JsonSerializer.Deserialize(json); + + Assert.AreEqual(ContractPkgHash, restored.ContractPackageHash); + } + + [Test] + public void ContractSchema_Serializes_SnakeCasePropertyNames() + { + var schema = BuildAnnotatedSchema(); + + var json = JsonSerializer.Serialize(schema); + + Assert.IsTrue(json.Contains("\"events\"")); + Assert.IsTrue(json.Contains("\"contract_hash\"")); + Assert.IsTrue(json.Contains("\"contract_package_hash\"")); + } + + [Test] + public void ContractSchema_WithNullHashes_RoundTripPreservesNulls() + { + var schema = CESContractSchema.ParseSchema(Hex.Decode(SchemaHex)); + + var json = JsonSerializer.Serialize(schema); + var restored = JsonSerializer.Deserialize(json); + + Assert.IsNull(restored.ContractHash); + Assert.IsNull(restored.ContractPackageHash); + } + + // ── CESEvent ────────────────────────────────────────────────────────── + + [Test] + public void CESEvent_RoundTrip_PreservesName() + { + var evt = BuildAnnotatedEvent(); + + var json = JsonSerializer.Serialize(evt); + var restored = JsonSerializer.Deserialize(json); + + Assert.AreEqual("Transfer", restored.Name); + } + + [Test] + public void CESEvent_RoundTrip_PreservesFieldCount() + { + var evt = BuildAnnotatedEvent(); + + var json = JsonSerializer.Serialize(evt); + var restored = JsonSerializer.Deserialize(json); + + Assert.AreEqual(2, restored.Fields.Count); + } + + [Test] + public void CESEvent_RoundTrip_PreservesU512Field() + { + var evt = BuildAnnotatedEvent(); + + var json = JsonSerializer.Serialize(evt); + var restored = JsonSerializer.Deserialize(json); + + var amount = restored["amount"]; + Assert.IsNotNull(amount); + Assert.AreEqual(CLType.U512, amount.TypeInfo.Type); + Assert.AreEqual(new BigInteger(100), amount.ToBigInteger()); + } + + [Test] + public void CESEvent_RoundTrip_PreservesStringField() + { + var evt = BuildAnnotatedEvent(); + + var json = JsonSerializer.Serialize(evt); + var restored = JsonSerializer.Deserialize(json); + + var sender = restored["sender"]; + Assert.IsNotNull(sender); + Assert.AreEqual(CLType.String, sender.TypeInfo.Type); + Assert.AreEqual("Alice", sender.ToString()); + } + + [Test] + public void CESEvent_RoundTrip_PreservesContextFields() + { + var evt = BuildAnnotatedEvent(); + + var json = JsonSerializer.Serialize(evt); + var restored = JsonSerializer.Deserialize(json); + + Assert.AreEqual(ContractHash, restored.ContractHash); + Assert.AreEqual(ContractPkgHash, restored.ContractPackageHash); + Assert.AreEqual(3, restored.TransformId); + Assert.AreEqual("42", restored.EventId); + } + + [Test] + public void CESEvent_Serializes_SnakeCasePropertyNames() + { + var evt = BuildAnnotatedEvent(); + + var json = JsonSerializer.Serialize(evt); + + Assert.IsTrue(json.Contains("\"name\"")); + Assert.IsTrue(json.Contains("\"fields\"")); + Assert.IsTrue(json.Contains("\"contract_hash\"")); + Assert.IsTrue(json.Contains("\"contract_package_hash\"")); + Assert.IsTrue(json.Contains("\"transform_id\"")); + Assert.IsTrue(json.Contains("\"event_id\"")); + } + + [Test] + public void CESEvent_FieldsSerializeAsArrayOfPairs() + { + var evt = BuildAnnotatedEvent(); + + // Each field must serialize as ["fieldName", {CLValue}], not as an object. + var json = JsonSerializer.Serialize(evt); + using var doc = JsonDocument.Parse(json); + var fields = doc.RootElement.GetProperty("fields"); + + Assert.AreEqual(JsonValueKind.Array, fields.ValueKind); + + var first = fields[0]; + Assert.AreEqual(JsonValueKind.Array, first.ValueKind); + Assert.AreEqual("amount", first[0].GetString()); + Assert.AreEqual(JsonValueKind.Object, first[1].ValueKind); + + var second = fields[1]; + Assert.AreEqual("sender", second[0].GetString()); + } + } +} diff --git a/Casper.Network.SDK.Test/CESParserGetEventsTest.cs b/Casper.Network.SDK.Test/CESParserGetEventsTest.cs new file mode 100644 index 0000000..1d62e00 --- /dev/null +++ b/Casper.Network.SDK.Test/CESParserGetEventsTest.cs @@ -0,0 +1,674 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Numerics; +using Casper.Network.SDK.ByteSerializers; +using Casper.Network.SDK.CES; +using Casper.Network.SDK.Types; +using NUnit.Framework; +using Org.BouncyCastle.Utilities.Encoders; + +namespace NetCasperTest +{ + [TestFixture] + public class CESParserGetEventsTest + { + // ─── shared schema / event fixtures ──────────────────────────────────── + // + // Schema: one event "Transfer" with fields amount (U512) and sender (String) + + private static readonly string SchemaHex = string.Concat( + "01000000", // 1 event + "08000000", // len("Transfer") = 8 + "5472616e73666572", // "Transfer" + "02000000", // 2 fields + "06000000", // len("amount") = 6 + "616d6f756e74", // "amount" + "08", // CLType.U512 + "06000000", // len("sender") = 6 + "73656e646572", // "sender" + "0a" // CLType.String + ); + + // event_Transfer(amount=100, sender="Alice") + // U512(100) → length byte 0x01 + value 0x64 + // String → 4-byte LE length + UTF-8 + private static readonly string EventHex = string.Concat( + "0e000000", // CLString len = 14 + "6576656e745f5472616e73666572", // "event_Transfer" + "01", "64", // U512(100) + "05000000", "416c696365" // String("Alice") + ); + + // event_Transfer(amount=200, sender="Bob") + private static readonly string EventBobHex = string.Concat( + "0e000000", // CLString len = 14 + "6576656e745f5472616e73666572", // "event_Transfer" + "01", "c8", // U512(200) + "03000000", "426f62" // String("Bob") + ); + + // Schema for a second contract: one event "Mint" with field recipient (U512) + private static readonly string SchemaHex2 = string.Concat( + "01000000", // 1 event + "04000000", // len("Mint") = 4 + "4d696e74", // "Mint" + "01000000", // 1 field + "09000000", // len("recipient") = 9 + "726563697069656e74", // "recipient" + "08" // CLType.U512 + ); + + // event_Mint(recipient=200) + private static readonly string EventMintHex = string.Concat( + "0a000000", // CLString len = 10 + "6576656e745f4d696e74", // "event_Mint" + "01", "c8" // U512(200) + ); + + // 32-byte seeds (hex-encoded, 64 chars each) + private const string SeedHex = + "aabbccddaabbccddaabbccddaabbccddaabbccddaabbccddaabbccddaabbccdd"; + + private const string SeedHex2 = + "1122334411223344112233441122334411223344112233441122334411223344"; + + // 32-byte dictionary global-state key hashes (different from the seeds) + private const string DictKeyHex = + "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"; + + private const string DictKeyHex2 = + "cafebabecafebabecafebabecafebabecafebabecafebabecafebabecafebabe"; + + // ─── helpers ─────────────────────────────────────────────────────────── + + private static CESContractSchema ParseSchema(string schemaHex) => + CESContractSchema.ParseSchema(Hex.Decode(schemaHex)); + + private static CESContractSchema MakeWatchedSchema(string schemaHex, string seedHex) => + new CESContractSchema(CESContractSchema.ParseSchema(Hex.Decode(schemaHex)).Events) + { + EventsURef = new URef($"uref-{seedHex}-000") + }; + + /// + /// Builds the raw bytes that expects. + /// Format: + /// [4B outer length] [CLValue blob (length + data + type)] [4B seed len] [seed bytes] + /// [4B key len] [key bytes] + /// The event payload is wrapped as a CLValue.ByteArray so that + /// dict.Value equals the original bytes. + /// + private static byte[] BuildDictBytes(byte[] eventPayload, byte[] seedBytes, string itemKey) + { + // CLValueByteSerializer.ToBytes produces the exact bytes that FromReader consumes: + // [4B data length] [N data bytes] [type bytes] + var clValueBlob = new CLValueByteSerializer().ToBytes(CLValue.ByteArray(eventPayload)); + + using var ms = new MemoryStream(); + + // outer length = total bytes that FromReader() will read + WriteU32LE(ms, (uint)clValueBlob.Length); + ms.Write(clValueBlob, 0, clValueBlob.Length); + + // seed: 4-byte LE count + raw bytes + WriteU32LE(ms, (uint)seedBytes.Length); + ms.Write(seedBytes, 0, seedBytes.Length); + + // item key: 4-byte LE count + UTF-8 + var keyBytes = System.Text.Encoding.UTF8.GetBytes(itemKey); + WriteU32LE(ms, (uint)keyBytes.Length); + ms.Write(keyBytes, 0, keyBytes.Length); + + return ms.ToArray(); + } + + private static void WriteU32LE(MemoryStream ms, uint value) + { + var bytes = BitConverter.GetBytes(value); + if (!BitConverter.IsLittleEndian) Array.Reverse(bytes); + ms.Write(bytes, 0, bytes.Length); + } + + /// + /// Creates a that looks exactly like a CES dictionary write. + /// + private static Transform MakeCESTransform( + string dictKeyHex, byte[] eventPayload, byte[] seedBytes, string itemKey = "1") + { + var clValue = new CLValue( + BuildDictBytes(eventPayload, seedBytes, itemKey), + new CLTypeInfo(CLType.Any)); + + return new Transform + { + Key = GlobalStateKey.FromString($"dictionary-{dictKeyHex}"), + Kind = new WriteTransformKind { Value = new StoredValue { CLValue = clValue } } + }; + } + + // ─── guard tests ─────────────────────────────────────────────────────── + + [Test] + public void GetEvents_NullTransforms_ThrowsArgumentNullException() + { + Assert.Throws(() => + CESParser.GetEvents(null, new List())); + } + + [Test] + public void GetEvents_NullWatchedContracts_ThrowsArgumentNullException() + { + Assert.Throws(() => + CESParser.GetEvents(new List(), null)); + } + + // ─── empty inputs ────────────────────────────────────────────────────── + + [Test] + public void GetEvents_EmptyTransforms_ReturnsEmptyList() + { + var result = CESParser.GetEvents( + new List(), + new List { MakeWatchedSchema(SchemaHex, SeedHex) }); + + Assert.IsNotNull(result); + Assert.AreEqual(0, result.Count); + } + + [Test] + public void GetEvents_EmptyWatchedContracts_ReturnsEmptyList() + { + var transform = MakeCESTransform(DictKeyHex, Hex.Decode(EventHex), Hex.Decode(SeedHex)); + + var result = CESParser.GetEvents( + new List { transform }, + new List()); + + Assert.AreEqual(0, result.Count); + } + + // ─── filter: key type ────────────────────────────────────────────────── + + [Test] + public void GetEvents_AccountHashKeyTransform_IsSkipped() + { + var transform = new Transform + { + Key = GlobalStateKey.FromString( + "account-hash-989ca079a5e446071866331468ab949483162588d57ec13ba6bb051f1e15f8b7"), + Kind = new WriteTransformKind + { + Value = new StoredValue + { + CLValue = new CLValue( + BuildDictBytes(Hex.Decode(EventHex), Hex.Decode(SeedHex), "1"), + new CLTypeInfo(CLType.Any)) + } + } + }; + + var result = CESParser.GetEvents( + new List { transform }, + new List { MakeWatchedSchema(SchemaHex, SeedHex) }); + + Assert.AreEqual(0, result.Count); + } + + [Test] + public void GetEvents_HashKeyTransform_IsSkipped() + { + var transform = new Transform + { + Key = GlobalStateKey.FromString( + "hash-989ca079a5e446071866331468ab949483162588d57ec13ba6bb051f1e15f8b7"), + Kind = new WriteTransformKind + { + Value = new StoredValue + { + CLValue = new CLValue( + BuildDictBytes(Hex.Decode(EventHex), Hex.Decode(SeedHex), "1"), + new CLTypeInfo(CLType.Any)) + } + } + }; + + var result = CESParser.GetEvents( + new List { transform }, + new List { MakeWatchedSchema(SchemaHex, SeedHex) }); + + Assert.AreEqual(0, result.Count); + } + + // ─── filter: transform kind ──────────────────────────────────────────── + + [Test] + public void GetEvents_IdentityTransformOnDictionaryKey_IsSkipped() + { + var transform = new Transform + { + Key = GlobalStateKey.FromString($"dictionary-{DictKeyHex}"), + Kind = new IdentityTransformKind() + }; + + var result = CESParser.GetEvents( + new List { transform }, + new List { MakeWatchedSchema(SchemaHex, SeedHex) }); + + Assert.AreEqual(0, result.Count); + } + + [Test] + public void GetEvents_AddInt32TransformOnDictionaryKey_IsSkipped() + { + var transform = new Transform + { + Key = GlobalStateKey.FromString($"dictionary-{DictKeyHex}"), + Kind = new AddInt32TransformKind { Value = 42 } + }; + + var result = CESParser.GetEvents( + new List { transform }, + new List { MakeWatchedSchema(SchemaHex, SeedHex) }); + + Assert.AreEqual(0, result.Count); + } + + [Test] + public void GetEvents_WriteWithoutCLValue_IsSkipped() + { + // Write of an Account (or any other StoredValue without CLValue set) + var transform = new Transform + { + Key = GlobalStateKey.FromString($"dictionary-{DictKeyHex}"), + Kind = new WriteTransformKind { Value = new StoredValue() } + }; + + var result = CESParser.GetEvents( + new List { transform }, + new List { MakeWatchedSchema(SchemaHex, SeedHex) }); + + Assert.AreEqual(0, result.Count); + } + + // ─── filter: seed matching ───────────────────────────────────────────── + + [Test] + public void GetEvents_DictionaryWriteWithUnwatchedSeed_IsSkipped() + { + const string differentSeed = + "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"; + + var transform = MakeCESTransform( + DictKeyHex, Hex.Decode(EventHex), Hex.Decode(differentSeed)); + + var result = CESParser.GetEvents( + new List { transform }, + new List { MakeWatchedSchema(SchemaHex, SeedHex) }); + + Assert.AreEqual(0, result.Count); + } + + [Test] + public void GetEvents_SeedMatchIgnoresAccessRights() + { + // CLValueDictionary.Parse always creates the seed with AccessRights.NONE. + // The watched-contract key is the plain 32-byte hash (no access rights). + // GetEvents must still find the match. + var transform = MakeCESTransform(DictKeyHex, Hex.Decode(EventHex), Hex.Decode(SeedHex)); + var watched = new List { MakeWatchedSchema(SchemaHex, SeedHex) }; + + var result = CESParser.GetEvents(new List { transform }, watched); + + Assert.AreEqual(1, result.Count); + } + + // ─── happy-path: single event ────────────────────────────────────────── + + [Test] + public void GetEvents_SingleMatchingTransform_ReturnsSingleEvent() + { + var transform = MakeCESTransform(DictKeyHex, Hex.Decode(EventHex), Hex.Decode(SeedHex)); + var watched = new List { MakeWatchedSchema(SchemaHex, SeedHex) }; + + var result = CESParser.GetEvents(new List { transform }, watched); + + Assert.AreEqual(1, result.Count); + } + + [Test] + public void GetEvents_SingleMatchingTransform_CorrectEventName() + { + var transform = MakeCESTransform(DictKeyHex, Hex.Decode(EventHex), Hex.Decode(SeedHex)); + var watched = new List { MakeWatchedSchema(SchemaHex, SeedHex) }; + + var result = CESParser.GetEvents(new List { transform }, watched); + + Assert.AreEqual("Transfer", result[0].Name); + } + + [Test] + public void GetEvents_SingleMatchingTransform_CorrectU512Field() + { + var transform = MakeCESTransform(DictKeyHex, Hex.Decode(EventHex), Hex.Decode(SeedHex)); + var watched = new List { MakeWatchedSchema(SchemaHex, SeedHex) }; + + var result = CESParser.GetEvents(new List { transform }, watched); + + Assert.AreEqual(new BigInteger(100), result[0]["amount"].ToBigInteger()); + } + + [Test] + public void GetEvents_SingleMatchingTransform_CorrectStringField() + { + var transform = MakeCESTransform(DictKeyHex, Hex.Decode(EventHex), Hex.Decode(SeedHex)); + var watched = new List { MakeWatchedSchema(SchemaHex, SeedHex) }; + + var result = CESParser.GetEvents(new List { transform }, watched); + + Assert.AreEqual("Alice", result[0]["sender"].ToString()); + } + + // ─── graceful skipping ───────────────────────────────────────────────── + + [Test] + public void GetEvents_EventNameAbsentFromSchema_IsSkipped() + { + // EventMintHex has name "event_Mint", but the watched schema only knows "Transfer". + // ParseEvent throws KeyNotFoundException → GetEvents silently skips. + var transform = MakeCESTransform(DictKeyHex, Hex.Decode(EventMintHex), Hex.Decode(SeedHex)); + var watched = new List { MakeWatchedSchema(SchemaHex, SeedHex) }; + + var result = CESParser.GetEvents(new List { transform }, watched); + + Assert.AreEqual(0, result.Count); + } + + [Test] + public void GetEvents_UnparsableDictionaryBytes_IsSkipped() + { + // Garbage bytes that CLValueDictionary.Parse will fail on → skipped, no exception. + var clValue = new CLValue(new byte[] { 0xDE, 0xAD, 0xBE, 0xEF }, new CLTypeInfo(CLType.Any)); + var transform = new Transform + { + Key = GlobalStateKey.FromString($"dictionary-{DictKeyHex}"), + Kind = new WriteTransformKind { Value = new StoredValue { CLValue = clValue } } + }; + var watched = new List { MakeWatchedSchema(SchemaHex, SeedHex) }; + + Assert.DoesNotThrow(() => + { + var result = CESParser.GetEvents(new List { transform }, watched); + Assert.AreEqual(0, result.Count); + }); + } + + // ─── multiple events ─────────────────────────────────────────────────── + + [Test] + public void GetEvents_TwoEventsFromSameContract_ReturnsBoth() + { + // Two dictionary writes with the same seed but different item keys + var t1 = MakeCESTransform(DictKeyHex, Hex.Decode(EventHex), Hex.Decode(SeedHex), "1"); + var t2 = MakeCESTransform(DictKeyHex2, Hex.Decode(EventBobHex), Hex.Decode(SeedHex), "2"); + var watched = new List { MakeWatchedSchema(SchemaHex, SeedHex) }; + + var result = CESParser.GetEvents(new List { t1, t2 }, watched); + + Assert.AreEqual(2, result.Count); + } + + [Test] + public void GetEvents_TwoEventsFromDifferentContracts_ReturnsBoth() + { + var t1 = MakeCESTransform(DictKeyHex, Hex.Decode(EventHex), Hex.Decode(SeedHex)); + var t2 = MakeCESTransform(DictKeyHex2, Hex.Decode(EventMintHex), Hex.Decode(SeedHex2)); + var watched = new List + { + MakeWatchedSchema(SchemaHex, SeedHex), + MakeWatchedSchema(SchemaHex2, SeedHex2), + }; + + var result = CESParser.GetEvents(new List { t1, t2 }, watched); + + Assert.AreEqual(2, result.Count); + } + + [Test] + public void GetEvents_TwoEventsFromDifferentContracts_CorrectFieldValues() + { + var t1 = MakeCESTransform(DictKeyHex, Hex.Decode(EventHex), Hex.Decode(SeedHex)); + var t2 = MakeCESTransform(DictKeyHex2, Hex.Decode(EventMintHex), Hex.Decode(SeedHex2)); + var watched = new List + { + MakeWatchedSchema(SchemaHex, SeedHex), + MakeWatchedSchema(SchemaHex2, SeedHex2), + }; + + var result = CESParser.GetEvents(new List { t1, t2 }, watched); + + // first event: Transfer – check both fields + Assert.AreEqual("Transfer", result[0].Name); + Assert.AreEqual(new BigInteger(100), result[0]["amount"].ToBigInteger()); + Assert.AreEqual("Alice", result[0]["sender"].ToString()); + + // second event: Mint – check recipient + Assert.AreEqual("Mint", result[1].Name); + Assert.AreEqual(new BigInteger(200), result[1]["recipient"].ToBigInteger()); + } + + // ─── ordering and mixed transforms ──────────────────────────────────── + + [Test] + public void GetEvents_OrderPreserved_MatchesTransformOrder() + { + var watched = new List { MakeWatchedSchema(SchemaHex, SeedHex) }; + + // Alice (item "1") comes before Bob (item "2") in the transform list + var t1 = MakeCESTransform(DictKeyHex, Hex.Decode(EventHex), Hex.Decode(SeedHex), "1"); + var t2 = MakeCESTransform(DictKeyHex2, Hex.Decode(EventBobHex), Hex.Decode(SeedHex), "2"); + + var result = CESParser.GetEvents(new List { t1, t2 }, watched); + + Assert.AreEqual(2, result.Count); + Assert.AreEqual("Alice", result[0]["sender"].ToString()); + Assert.AreEqual("Bob", result[1]["sender"].ToString()); + } + + [Test] + public void GetEvents_MixedTransforms_OnlyMatchingEventsReturned() + { + // t1: Identity on a dictionary key → skipped (wrong kind) + var t1 = new Transform + { + Key = GlobalStateKey.FromString($"dictionary-{DictKeyHex}"), + Kind = new IdentityTransformKind() + }; + // t2: CES event from watched contract → included + var t2 = MakeCESTransform(DictKeyHex, Hex.Decode(EventHex), Hex.Decode(SeedHex)); + // t3: Write of CLValue to an account-hash key → skipped (wrong key type) + var t3 = new Transform + { + Key = GlobalStateKey.FromString( + "account-hash-989ca079a5e446071866331468ab949483162588d57ec13ba6bb051f1e15f8b7"), + Kind = new WriteTransformKind { Value = new StoredValue { CLValue = CLValue.U32(1) } } + }; + // t4: dictionary write with an unwatched seed → skipped + var t4 = MakeCESTransform(DictKeyHex2, Hex.Decode(EventHex), Hex.Decode(SeedHex2)); + + var watched = new List { MakeWatchedSchema(SchemaHex, SeedHex) }; + + var result = CESParser.GetEvents(new List { t1, t2, t3, t4 }, watched); + + Assert.AreEqual(1, result.Count); + Assert.AreEqual("Transfer", result[0].Name); + } + + [Test] + public void GetEvents_OneValidOneBadParseable_OnlyValidEventReturned() + { + // t1: valid CES event + var t1 = MakeCESTransform(DictKeyHex, Hex.Decode(EventHex), Hex.Decode(SeedHex)); + // t2: garbled CLValue bytes that will fail CLValueDictionary.Parse → skipped + var t2 = new Transform + { + Key = GlobalStateKey.FromString($"dictionary-{DictKeyHex2}"), + Kind = new WriteTransformKind + { + Value = new StoredValue + { + CLValue = new CLValue(new byte[] { 0x00, 0x01 }, new CLTypeInfo(CLType.Any)) + } + } + }; + var watched = new List { MakeWatchedSchema(SchemaHex, SeedHex) }; + + var result = CESParser.GetEvents(new List { t1, t2 }, watched); + + Assert.AreEqual(1, result.Count); + Assert.AreEqual("Transfer", result[0].Name); + } + + // ─── TransformId and EventId ─────────────────────────────────────────── + + [Test] + public void GetEvents_SingleEvent_TransformIdIsIndexInList() + { + // t0 is a non-matching transform at index 0; t1 is the CES event at index 1. + var t0 = new Transform + { + Key = GlobalStateKey.FromString( + "account-hash-989ca079a5e446071866331468ab949483162588d57ec13ba6bb051f1e15f8b7"), + Kind = new IdentityTransformKind() + }; + var t1 = MakeCESTransform(DictKeyHex, Hex.Decode(EventHex), Hex.Decode(SeedHex), "7"); + var watched = new List { MakeWatchedSchema(SchemaHex, SeedHex) }; + + var result = CESParser.GetEvents(new List { t0, t1 }, watched); + + Assert.AreEqual(1, result.Count); + Assert.AreEqual(1, result[0].TransformId); // index 1 in the list + } + + [Test] + public void GetEvents_SingleEvent_EventIdMatchesDictItemKey() + { + var t0 = MakeCESTransform(DictKeyHex, Hex.Decode(EventHex), Hex.Decode(SeedHex), itemKey: "42"); + var watched = new List { MakeWatchedSchema(SchemaHex, SeedHex) }; + + var result = CESParser.GetEvents(new List { t0 }, watched); + + Assert.AreEqual("42", result[0].EventId); + } + + [Test] + public void GetEvents_MultipleEvents_TransformIdsAreDistinct() + { + var t0 = MakeCESTransform(DictKeyHex, Hex.Decode(EventHex), Hex.Decode(SeedHex), "1"); + var t1 = MakeCESTransform(DictKeyHex2, Hex.Decode(EventBobHex), Hex.Decode(SeedHex), "2"); + var watched = new List { MakeWatchedSchema(SchemaHex, SeedHex) }; + + var result = CESParser.GetEvents(new List { t0, t1 }, watched); + + Assert.AreEqual(2, result.Count); + Assert.AreEqual(0, result[0].TransformId); + Assert.AreEqual(1, result[1].TransformId); + } + + [Test] + public void GetEvents_MultipleEvents_EventIdsMatchItemKeys() + { + var t0 = MakeCESTransform(DictKeyHex, Hex.Decode(EventHex), Hex.Decode(SeedHex), "100"); + var t1 = MakeCESTransform(DictKeyHex2, Hex.Decode(EventBobHex), Hex.Decode(SeedHex), "101"); + var watched = new List { MakeWatchedSchema(SchemaHex, SeedHex) }; + + var result = CESParser.GetEvents(new List { t0, t1 }, watched); + + Assert.AreEqual("100", result[0].EventId); + Assert.AreEqual("101", result[1].EventId); + } + + // ─── ContractHash / ContractPackageHash propagation ─────────────────── + + [Test] + public void GetEvents_SchemaWithContractHash_EventCarriesContractHash() + { + const string contractHash = "hash-aabbccddaabbccddaabbccddaabbccddaabbccddaabbccddaabbccddaabbccdd"; + var schema = new CESContractSchema(CESContractSchema.ParseSchema(Hex.Decode(SchemaHex)).Events) + { + ContractHash = contractHash, + EventsURef = new URef($"uref-{SeedHex}-000"), + }; + var transform = MakeCESTransform(DictKeyHex, Hex.Decode(EventHex), Hex.Decode(SeedHex)); + var watched = new List { schema }; + + var result = CESParser.GetEvents(new List { transform }, watched); + + Assert.AreEqual(contractHash, result[0].ContractHash); + } + + [Test] + public void GetEvents_SchemaWithContractPackageHash_EventCarriesContractPackageHash() + { + const string pkgHash = "contract-package-hash-1122334411223344112233441122334411223344112233441122334411223344"; + var schema = new CESContractSchema(CESContractSchema.ParseSchema(Hex.Decode(SchemaHex)).Events) + { + ContractPackageHash = pkgHash, + EventsURef = new URef($"uref-{SeedHex}-000"), + }; + var transform = MakeCESTransform(DictKeyHex, Hex.Decode(EventHex), Hex.Decode(SeedHex)); + var watched = new List { schema }; + + var result = CESParser.GetEvents(new List { transform }, watched); + + Assert.AreEqual(pkgHash, result[0].ContractPackageHash); + } + + [Test] + public void GetEvents_SchemaWithoutHashes_EventHashesAreNull() + { + var watched = new List { MakeWatchedSchema(SchemaHex, SeedHex) }; + var transform = MakeCESTransform(DictKeyHex, Hex.Decode(EventHex), Hex.Decode(SeedHex)); + + var result = CESParser.GetEvents(new List { transform }, watched); + + Assert.IsNull(result[0].ContractHash); + Assert.IsNull(result[0].ContractPackageHash); + } + + [Test] + public void GetEvents_TwoDifferentContracts_EventsCarryCorrectHashes() + { + const string hash1 = "hash-aabbccddaabbccddaabbccddaabbccddaabbccddaabbccddaabbccddaabbccdd"; + const string hash2 = "hash-1122334411223344112233441122334411223344112233441122334411223344"; + + var schema1 = new CESContractSchema(CESContractSchema.ParseSchema(Hex.Decode(SchemaHex)).Events) + { ContractHash = hash1, EventsURef = new URef($"uref-{SeedHex}-000") }; + var schema2 = new CESContractSchema(CESContractSchema.ParseSchema(Hex.Decode(SchemaHex2)).Events) + { ContractHash = hash2, EventsURef = new URef($"uref-{SeedHex2}-000") }; + + var t1 = MakeCESTransform(DictKeyHex, Hex.Decode(EventHex), Hex.Decode(SeedHex)); + var t2 = MakeCESTransform(DictKeyHex2, Hex.Decode(EventMintHex), Hex.Decode(SeedHex2)); + var watched = new List { schema1, schema2 }; + + var result = CESParser.GetEvents(new List { t1, t2 }, watched); + + Assert.AreEqual(2, result.Count); + Assert.AreEqual(hash1, result[0].ContractHash); + Assert.AreEqual(hash2, result[1].ContractHash); + } + + // ─── ParseEvent in isolation ─────────────────────────────────────────── + + [Test] + public void ParseEvent_InIsolation_TransformIdIsZeroAndEventIdIsNull() + { + var schema = ParseSchema(SchemaHex); + var evt = CESEvent.ParseEvent(Hex.Decode(EventHex), schema); + + Assert.AreEqual(0, evt.TransformId); + Assert.IsNull(evt.TransformKey); + Assert.IsNull(evt.EventId); + } + } +} diff --git a/Casper.Network.SDK.Test/CESParserTest.cs b/Casper.Network.SDK.Test/CESParserTest.cs new file mode 100644 index 0000000..a2605a7 --- /dev/null +++ b/Casper.Network.SDK.Test/CESParserTest.cs @@ -0,0 +1,235 @@ +using System; +using System.Collections.Generic; +using System.Numerics; +using System.Runtime; +using Casper.Network.SDK.CES; +using Casper.Network.SDK.Types; +using NUnit.Framework; +using Org.BouncyCastle.Utilities.Encoders; + +namespace NetCasperTest +{ + [TestFixture] + public class CESParserTest + { + // Schema binary for one event "Transfer" with two fields: + // amount (CLType.U512 = 0x08) + // sender (CLType.String = 0x0a) + private static readonly string SchemaHex = string.Concat( + "01000000", // 1 event + "08000000", // len("Transfer") = 8 + "5472616e73666572", // "Transfer" + "02000000", // 2 fields + "06000000", // len("amount") = 6 + "616d6f756e74", // "amount" + "08", // CLType.U512 + "06000000", // len("sender") = 6 + "73656e646572", // "sender" + "0a" // CLType.String + ); + + // Event binary for event_Transfer(amount=100, sender="Alice") + // U512(100) serializes as: 0x01 (length byte) + 0x64 (100 in one byte) + // String("Alice") serializes as: 0x05000000 (length) + 0x416c696365 + private static readonly string EventHex = string.Concat( + "0e000000", // len("event_Transfer") = 14 + "6576656e745f5472616e73666572", // "event_Transfer" + "01", // U512 length = 1 byte + "64", // U512 value = 100 (0x64) + "05000000", // len("Alice") = 5 + "416c696365" // "Alice" + ); + + [Test] + public void ParseSchema_SingleEvent_CorrectEventName() + { + var schema = CESContractSchema.ParseSchema(Hex.Decode(SchemaHex)); + + Assert.IsTrue(schema.TryGetEventSchema("Transfer", out var eventSchema)); + Assert.AreEqual("Transfer", eventSchema.EventName); + } + + [Test] + public void ParseSchema_SingleEvent_CorrectFieldCount() + { + var schema = CESContractSchema.ParseSchema(Hex.Decode(SchemaHex)); + + schema.TryGetEventSchema("Transfer", out var eventSchema); + Assert.AreEqual(2, eventSchema.Fields.Count); + } + + [Test] + public void ParseSchema_SingleEvent_CorrectFieldDefinitions() + { + var schema = CESContractSchema.ParseSchema(Hex.Decode(SchemaHex)); + + schema.TryGetEventSchema("Transfer", out var eventSchema); + + Assert.AreEqual("amount", eventSchema.Fields[0].Name); + Assert.AreEqual(CLType.U512, eventSchema.Fields[0].CLTypeInfo.Type); + + Assert.AreEqual("sender", eventSchema.Fields[1].Name); + Assert.AreEqual(CLType.String, eventSchema.Fields[1].CLTypeInfo.Type); + } + + [Test] + public void ParseSchema_UnknownEvent_ReturnsFalse() + { + var schema = CESContractSchema.ParseSchema(Hex.Decode(SchemaHex)); + + Assert.IsFalse(schema.TryGetEventSchema("Mint", out _)); + } + + [Test] + public void ParseEvent_CorrectEventName() + { + var schema = CESContractSchema.ParseSchema(Hex.Decode(SchemaHex)); + var evt = CESEvent.ParseEvent(Hex.Decode(EventHex), schema); + + Assert.AreEqual("Transfer", evt.Name); + } + + [Test] + public void ParseEvent_CorrectFieldCount() + { + var schema = CESContractSchema.ParseSchema(Hex.Decode(SchemaHex)); + var evt = CESEvent.ParseEvent(Hex.Decode(EventHex), schema); + + Assert.AreEqual(2, evt.Fields.Count); + } + + [Test] + public void ParseEvent_U512FieldCorrectValue() + { + var schema = CESContractSchema.ParseSchema(Hex.Decode(SchemaHex)); + var evt = CESEvent.ParseEvent(Hex.Decode(EventHex), schema); + + var amountField = evt["amount"]; + Assert.IsNotNull(amountField); + Assert.AreEqual(CLType.U512, amountField.TypeInfo.Type); + Assert.AreEqual(new BigInteger(100), amountField.ToBigInteger()); + } + + [Test] + public void ParseEvent_StringFieldCorrectValue() + { + var schema = CESContractSchema.ParseSchema(Hex.Decode(SchemaHex)); + var evt = CESEvent.ParseEvent(Hex.Decode(EventHex), schema); + + var senderField = evt["sender"]; + Assert.IsNotNull(senderField); + Assert.AreEqual(CLType.String, senderField.TypeInfo.Type); + Assert.AreEqual("Alice", senderField.ToString()); + } + + [Test] + public void ParseEvent_IndexerReturnsNullForMissingField() + { + var schema = CESContractSchema.ParseSchema(Hex.Decode(SchemaHex)); + var evt = CESEvent.ParseEvent(Hex.Decode(EventHex), schema); + + Assert.IsNull(evt["nonexistent"]); + } + + [Test] + public void ParseEvent_UnknownEventName_ThrowsKeyNotFoundException() + { + var schema = CESContractSchema.ParseSchema(Hex.Decode(SchemaHex)); + + // Build event bytes with an event name ("event_Mint") not present in the schema. + var mintEventHex = string.Concat( + "0a000000", // len("event_Mint") = 10 + "6576656e745f4d696e74" // "event_Mint" + ); + + Assert.Throws( + () => CESEvent.ParseEvent(Hex.Decode(mintEventHex), schema)); + } + + [Test] + public void ParseSchema_OptionType_CorrectInnerType() + { + // Schema with one event "Approve" and one Option(PublicKey) field "operator" + // CLType.Option = 0x0d, CLType.PublicKey = 0x16 + var schemaHex = string.Concat( + "01000000", // 1 event + "07000000", // len("Approve") = 7 + "417070726f7665", // "Approve" + "01000000", // 1 field + "08000000", // len("operator") = 8 + "6f70657261746f72", // "operator" + "0d", // CLType.Option + "16" // CLType.PublicKey + ); + + var schema = CESContractSchema.ParseSchema(Hex.Decode(schemaHex)); + + schema.TryGetEventSchema("Approve", out var eventSchema); + Assert.AreEqual("operator", eventSchema.Fields[0].Name); + Assert.AreEqual(CLType.Option, eventSchema.Fields[0].CLTypeInfo.Type); + + var optionType = (CLOptionTypeInfo)eventSchema.Fields[0].CLTypeInfo; + Assert.AreEqual(CLType.PublicKey, optionType.OptionType.Type); + } + + [Test] + public void ParseSchema_MapType_CorrectKeyAndValueTypes() + { + // Schema with one event "Allowances" and one Map(PublicKey, U256) field "data" + // CLType.Map = 0x11, CLType.PublicKey = 0x16, CLType.U256 = 0x07 + var schemaHex = string.Concat( + "01000000", // 1 event + "0a000000", // len("Allowances") = 10 + "416c6c6f77616e636573", // "Allowances" + "01000000", // 1 field + "04000000", // len("data") = 4 + "64617461", // "data" + "11", // CLType.Map + "16", // key: CLType.PublicKey + "07" // value: CLType.U256 + ); + + var schema = CESContractSchema.ParseSchema(Hex.Decode(schemaHex)); + + schema.TryGetEventSchema("Allowances", out var eventSchema); + Assert.AreEqual("data", eventSchema.Fields[0].Name); + Assert.AreEqual(CLType.Map, eventSchema.Fields[0].CLTypeInfo.Type); + + var mapType = (CLMapTypeInfo)eventSchema.Fields[0].CLTypeInfo; + Assert.AreEqual(CLType.PublicKey, mapType.KeyType.Type); + Assert.AreEqual(CLType.U256, mapType.ValueType.Type); + } + + [Test] + public void ParseSchema_TupleType_CorrectInnerTypes() + { + // Schema of a CEP-18 contract + var schemaHex = + "07000000040000004275726e02000000050000006f776e65720b06000000616d6f756e74071100000044656372656173" + + "65416c6c6f77616e636504000000050000006f776e65720b070000007370656e6465720b09000000616c6c6f77616e63" + + "650707000000646563725f62790711000000496e637265617365416c6c6f77616e636504000000050000006f776e6572"+ + "0b070000007370656e6465720b09000000616c6c6f77616e63650706000000696e635f627907040000004d696e740200" + + "000009000000726563697069656e740b06000000616d6f756e74070c000000536574416c6c6f77616e63650300000005" + + "0000006f776e65720b070000007370656e6465720b09000000616c6c6f77616e636507080000005472616e7366657203" + + "0000000600000073656e6465720b09000000726563697069656e740b06000000616d6f756e74070c0000005472616e73" + + "66657246726f6d04000000070000007370656e6465720b050000006f776e65720b09000000726563697069656e740b06" + + "000000616d6f756e7407"; + + var schema = CESContractSchema.ParseSchema(Hex.Decode(schemaHex)); + Assert.IsNotNull(schema); + schema.TryGetEventSchema("Transfer", out var eventSchema); + Assert.IsNotNull(eventSchema); + Assert.AreEqual("Transfer", eventSchema.EventName); + + var evt0 = + "0a0000006576656e745f4d696e74011262d06e53125ea098187fb4d1d5b10a7afed48e5e5eef182ed992fc5b10034908000064a7b3b6e00d"; + var parsedEvt = CESEvent.ParseEvent(Hex.Decode(evt0), schema); + Assert.IsNotNull(parsedEvt); + Assert.AreEqual("Mint", parsedEvt.Name); + var amount = parsedEvt["amount"]; + Assert.IsNotNull(amount); + Assert.IsNotNull(amount.ToBigInteger()); + Assert.AreEqual("1000000000000000000", amount.ToBigInteger().ToString() ); + } + } +} diff --git a/Casper.Network.SDK.Test/CLValueReaderTest.cs b/Casper.Network.SDK.Test/CLValueReaderTest.cs new file mode 100644 index 0000000..d0467ba --- /dev/null +++ b/Casper.Network.SDK.Test/CLValueReaderTest.cs @@ -0,0 +1,650 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Numerics; +using Casper.Network.SDK.ByteSerializers; +using Casper.Network.SDK.Types; +using NUnit.Framework; +using Org.BouncyCastle.Utilities.Encoders; + +namespace NetCasperTest +{ + public class CLValueReaderTest + { + // ─── helpers ─────────────────────────────────────────────────────────────── + + private static CLValueReader ReaderFor(byte[] bytes) => + new CLValueReader(new BinaryReader(new MemoryStream(bytes))); + + // ─── constructor guard ───────────────────────────────────────────────────── + + [Test] + public void NullReaderThrowsArgumentNullException() + { + Assert.Throws(() => new CLValueReader(null)); + } + + [Test] + public void NullTypeInfoThrowsArgumentNullException() + { + var reader = ReaderFor(Array.Empty()); + Assert.Throws(() => reader.Read(null)); + } + + // ─── primitives ──────────────────────────────────────────────────────────── + + [Test] + public void ReadBoolTest() + { + foreach (var value in new[] { false, true }) + { + var original = CLValue.Bool(value); + var result = ReaderFor(original.Bytes).Read(CLType.Bool); + + Assert.AreEqual(CLType.Bool, result.TypeInfo.Type); + Assert.AreEqual(Hex.ToHexString(original.Bytes), Hex.ToHexString(result.Bytes)); + Assert.AreEqual(value, result.ToBoolean()); + } + } + + [Test] + public void ReadI32Test() + { + foreach (var value in new[] { int.MinValue, -10, 0, 42, int.MaxValue }) + { + var original = CLValue.I32(value); + var result = ReaderFor(original.Bytes).Read(CLType.I32); + + Assert.AreEqual(CLType.I32, result.TypeInfo.Type); + Assert.AreEqual(Hex.ToHexString(original.Bytes), Hex.ToHexString(result.Bytes)); + Assert.AreEqual(value, result.ToInt32()); + } + } + + [Test] + public void ReadI64Test() + { + foreach (var value in new[] { long.MinValue, -16L, 0L, 1L, long.MaxValue }) + { + var original = CLValue.I64(value); + var result = ReaderFor(original.Bytes).Read(CLType.I64); + + Assert.AreEqual(CLType.I64, result.TypeInfo.Type); + Assert.AreEqual(Hex.ToHexString(original.Bytes), Hex.ToHexString(result.Bytes)); + Assert.AreEqual(value, result.ToInt64()); + } + } + + [Test] + public void ReadU8Test() + { + foreach (var value in new byte[] { 0x00, 0x7F, 0xFF }) + { + var original = CLValue.U8(value); + var result = ReaderFor(original.Bytes).Read(CLType.U8); + + Assert.AreEqual(CLType.U8, result.TypeInfo.Type); + Assert.AreEqual(Hex.ToHexString(original.Bytes), Hex.ToHexString(result.Bytes)); + Assert.AreEqual(value, result.ToByte()); + } + } + + [Test] + public void ReadU32Test() + { + foreach (var value in new uint[] { 0, 1, uint.MaxValue }) + { + var original = CLValue.U32(value); + var result = ReaderFor(original.Bytes).Read(CLType.U32); + + Assert.AreEqual(CLType.U32, result.TypeInfo.Type); + Assert.AreEqual(Hex.ToHexString(original.Bytes), Hex.ToHexString(result.Bytes)); + Assert.AreEqual(value, result.ToUInt32()); + } + } + + [Test] + public void ReadU64Test() + { + foreach (var value in new ulong[] { 0, 1, ulong.MaxValue }) + { + var original = CLValue.U64(value); + var result = ReaderFor(original.Bytes).Read(CLType.U64); + + Assert.AreEqual(CLType.U64, result.TypeInfo.Type); + Assert.AreEqual(Hex.ToHexString(original.Bytes), Hex.ToHexString(result.Bytes)); + Assert.AreEqual(value, result.ToUInt64()); + } + } + + [Test] + public void ReadU128Test() + { + // single-byte value + var small = new BigInteger(255); + var original = CLValue.U128(small); + var result = ReaderFor(original.Bytes).Read(CLType.U128); + Assert.AreEqual(CLType.U128, result.TypeInfo.Type); + Assert.AreEqual(small, result.ToBigInteger()); + + // ulong.MaxValue — multi-byte encoding + var large = new BigInteger(ulong.MaxValue); + original = CLValue.U128(large); + result = ReaderFor(original.Bytes).Read(CLType.U128); + Assert.AreEqual(CLType.U128, result.TypeInfo.Type); + Assert.AreEqual(Hex.ToHexString(original.Bytes), Hex.ToHexString(result.Bytes)); + Assert.AreEqual(large, result.ToBigInteger()); + } + + [Test] + public void ReadU256Test() + { + var value = new BigInteger(ulong.MaxValue); + var original = CLValue.U256(value); + var result = ReaderFor(original.Bytes).Read(CLType.U256); + + Assert.AreEqual(CLType.U256, result.TypeInfo.Type); + Assert.AreEqual(Hex.ToHexString(original.Bytes), Hex.ToHexString(result.Bytes)); + Assert.AreEqual(value, result.ToBigInteger()); + } + + [Test] + public void ReadU512Test() + { + var value = new BigInteger(ulong.MaxValue); + var original = CLValue.U512(value); + var result = ReaderFor(original.Bytes).Read(CLType.U512); + + Assert.AreEqual(CLType.U512, result.TypeInfo.Type); + Assert.AreEqual(Hex.ToHexString(original.Bytes), Hex.ToHexString(result.Bytes)); + Assert.AreEqual(value, result.ToBigInteger()); + } + + [Test] + public void ReadUnitTest() + { + var original = CLValue.Unit(); + var result = ReaderFor(original.Bytes).Read(CLType.Unit); + + Assert.AreEqual(CLType.Unit, result.TypeInfo.Type); + Assert.AreEqual(0, result.Bytes.Length); + } + + // ─── string ──────────────────────────────────────────────────────────────── + + [Test] + public void ReadStringTest() + { + var value = "Hello, Casper!"; + var original = CLValue.String(value); + var result = ReaderFor(original.Bytes).Read(CLType.String); + + Assert.AreEqual(CLType.String, result.TypeInfo.Type); + Assert.AreEqual(Hex.ToHexString(original.Bytes), Hex.ToHexString(result.Bytes)); + Assert.AreEqual(value, result.ToString()); + } + + [Test] + public void ReadEmptyStringTest() + { + var original = CLValue.String(string.Empty); + var result = ReaderFor(original.Bytes).Read(CLType.String); + + Assert.AreEqual(CLType.String, result.TypeInfo.Type); + Assert.AreEqual(string.Empty, result.ToString()); + } + + // ─── URef ────────────────────────────────────────────────────────────────── + + [Test] + public void ReadURefTest() + { + var urefStr = "uref-000102030405060708090a0b0c0d0e0f000102030405060708090a0b0c0d0e0f-007"; + var original = CLValue.URef(urefStr); + var result = ReaderFor(original.Bytes).Read(CLType.URef); + + Assert.AreEqual(CLType.URef, result.TypeInfo.Type); + Assert.AreEqual(33, result.Bytes.Length); + Assert.AreEqual(Hex.ToHexString(original.Bytes), Hex.ToHexString(result.Bytes)); + Assert.AreEqual(urefStr, result.ToURef().ToString()); + } + + // ─── PublicKey ───────────────────────────────────────────────────────────── + + [Test] + public void ReadPublicKeyEd25519Test() + { + var publicKey = KeyPair.CreateNew(KeyAlgo.ED25519).PublicKey; + var original = CLValue.PublicKey(publicKey); + var result = ReaderFor(original.Bytes).Read(CLType.PublicKey); + + Assert.AreEqual(CLType.PublicKey, result.TypeInfo.Type); + Assert.AreEqual(33, result.Bytes.Length); // 1 algo + 32 key bytes + Assert.AreEqual(Hex.ToHexString(original.Bytes), Hex.ToHexString(result.Bytes)); + Assert.AreEqual(publicKey.ToAccountHex(), result.ToPublicKey().ToAccountHex()); + } + + [Test] + public void ReadPublicKeySecp256k1Test() + { + var publicKey = KeyPair.CreateNew(KeyAlgo.SECP256K1).PublicKey; + var original = CLValue.PublicKey(publicKey); + var result = ReaderFor(original.Bytes).Read(CLType.PublicKey); + + Assert.AreEqual(CLType.PublicKey, result.TypeInfo.Type); + Assert.AreEqual(34, result.Bytes.Length); // 1 algo + 33 key bytes + Assert.AreEqual(Hex.ToHexString(original.Bytes), Hex.ToHexString(result.Bytes)); + Assert.AreEqual(publicKey.ToAccountHex(), result.ToPublicKey().ToAccountHex()); + } + + // ─── Key (GlobalStateKey) ────────────────────────────────────────────────── + + [Test] + public void ReadKeyAccountHashTest() + { + var key = GlobalStateKey.FromString( + "account-hash-989ca079a5e446071866331468ab949483162588d57ec13ba6bb051f1e15f8b7"); + var original = CLValue.Key(key); + var typeInfo = new CLKeyTypeInfo(KeyIdentifier.Account); + var result = ReaderFor(original.Bytes).Read(typeInfo); + + Assert.AreEqual(CLType.Key, result.TypeInfo.Type); + Assert.AreEqual(33, result.Bytes.Length); // 1 tag + 32 hash bytes + Assert.AreEqual(Hex.ToHexString(original.Bytes), Hex.ToHexString(result.Bytes)); + Assert.AreEqual(key.ToString(), result.ToGlobalStateKey().ToString()); + } + + [Test] + public void ReadKeyEraInfoTest() + { + var key = GlobalStateKey.FromString("era-2034"); + var original = CLValue.Key(key); + var typeInfo = new CLKeyTypeInfo(KeyIdentifier.EraInfo); + var result = ReaderFor(original.Bytes).Read(typeInfo); + + Assert.AreEqual(CLType.Key, result.TypeInfo.Type); + Assert.AreEqual(9, result.Bytes.Length); // 1 tag + 8 u64 bytes + Assert.AreEqual(Hex.ToHexString(original.Bytes), Hex.ToHexString(result.Bytes)); + Assert.AreEqual(key.ToString(), result.ToGlobalStateKey().ToString()); + } + + [Test] + public void ReadKeyURefTest() + { + var key = GlobalStateKey.FromString( + "uref-000102030405060708090a0b0c0d0e0f000102030405060708090a0b0c0d0e0f-007"); + var original = CLValue.Key(key); + var typeInfo = new CLKeyTypeInfo(KeyIdentifier.URef); + var result = ReaderFor(original.Bytes).Read(typeInfo); + + Assert.AreEqual(CLType.Key, result.TypeInfo.Type); + Assert.AreEqual(34, result.Bytes.Length); // 1 tag + 33 URef bytes + Assert.AreEqual(Hex.ToHexString(original.Bytes), Hex.ToHexString(result.Bytes)); + Assert.AreEqual(key.ToString(), result.ToGlobalStateKey().ToString()); + } + + // ─── Option ──────────────────────────────────────────────────────────────── + + [Test] + public void ReadOptionSomeTest() + { + var original = CLValue.Option(CLValue.U32(42u)); + var typeInfo = new CLOptionTypeInfo(CLType.U32); + var result = ReaderFor(original.Bytes).Read(typeInfo); + + Assert.AreEqual(CLType.Option, result.TypeInfo.Type); + Assert.AreEqual(CLType.U32, ((CLOptionTypeInfo)result.TypeInfo).OptionType.Type); + Assert.AreEqual(Hex.ToHexString(original.Bytes), Hex.ToHexString(result.Bytes)); + Assert.IsTrue(result.IsSome()); + Assert.AreEqual(0x01, result.Bytes[0]); // Some tag + // inner bytes after the tag should match U32(42).Bytes + var innerBytes = result.Bytes.Skip(1).ToArray(); + Assert.AreEqual(Hex.ToHexString(CLValue.U32(42u).Bytes), Hex.ToHexString(innerBytes)); + } + + [Test] + public void ReadOptionNoneTest() + { + var original = CLValue.OptionNone(CLType.U32); + var typeInfo = new CLOptionTypeInfo(CLType.U32); + var result = ReaderFor(original.Bytes).Read(typeInfo); + + Assert.AreEqual(CLType.Option, result.TypeInfo.Type); + Assert.AreEqual(CLType.U32, ((CLOptionTypeInfo)result.TypeInfo).OptionType.Type); + Assert.AreEqual(1, result.Bytes.Length); + Assert.AreEqual(0x00, result.Bytes[0]); // None tag + Assert.IsTrue(result.IsNone()); + } + + // ─── List ────────────────────────────────────────────────────────────────── + + [Test] + public void ReadListTest() + { + var original = CLValue.List(new[] + { + CLValue.U32(1), CLValue.U32(2), CLValue.U32(3), CLValue.U32(4) + }); + var typeInfo = new CLListTypeInfo(CLType.U32); + var result = ReaderFor(original.Bytes).Read(typeInfo); + + Assert.AreEqual(CLType.List, result.TypeInfo.Type); + Assert.AreEqual(CLType.U32, ((CLListTypeInfo)result.TypeInfo).ListType.Type); + Assert.AreEqual(Hex.ToHexString(original.Bytes), Hex.ToHexString(result.Bytes)); + + var items = result.ToList(); + Assert.AreEqual(4, items.Count); + Assert.AreEqual(1u, items[0]); + Assert.AreEqual(2u, items[1]); + Assert.AreEqual(3u, items[2]); + Assert.AreEqual(4u, items[3]); + } + + [Test] + public void ReadEmptyListTest() + { + var original = CLValue.EmptyList(new CLKeyTypeInfo(KeyIdentifier.Hash)); + var typeInfo = new CLListTypeInfo(new CLKeyTypeInfo(KeyIdentifier.Hash)); + var result = ReaderFor(original.Bytes).Read(typeInfo); + + Assert.AreEqual(CLType.List, result.TypeInfo.Type); + Assert.AreEqual(Hex.ToHexString(original.Bytes), Hex.ToHexString(result.Bytes)); + Assert.AreEqual(0, result.ToList().Count); + } + + // ─── ByteArray ───────────────────────────────────────────────────────────── + + [Test] + public void ReadByteArrayTest() + { + var data = Hex.Decode("0102030405060708"); + var original = CLValue.ByteArray(data); + var typeInfo = new CLByteArrayTypeInfo(8); + var result = ReaderFor(original.Bytes).Read(typeInfo); + + Assert.AreEqual(CLType.ByteArray, result.TypeInfo.Type); + Assert.AreEqual(8, result.Bytes.Length); + Assert.AreEqual(Hex.ToHexString(original.Bytes), Hex.ToHexString(result.Bytes)); + Assert.IsTrue(data.SequenceEqual(result.ToByteArray())); + } + + // ─── Result ──────────────────────────────────────────────────────────────── + + [Test] + public void ReadResultOkTest() + { + var original = CLValue.Ok(CLValue.U8(0xFF), new CLTypeInfo(CLType.String)); + var typeInfo = new CLResultTypeInfo(CLType.U8, CLType.String); + var result = ReaderFor(original.Bytes).Read(typeInfo); + + Assert.AreEqual(CLType.Result, result.TypeInfo.Type); + Assert.AreEqual(Hex.ToHexString(original.Bytes), Hex.ToHexString(result.Bytes)); + Assert.AreEqual(0x01, result.Bytes[0]); // Ok tag + var typed = result.ToResult(); + Assert.IsTrue(typed.Success); + Assert.AreEqual(0xFF, typed.Value); + } + + [Test] + public void ReadResultErrTest() + { + var original = CLValue.Err(CLValue.String("Error!"), new CLTypeInfo(CLType.Unit)); + var typeInfo = new CLResultTypeInfo(CLType.Unit, CLType.String); + var result = ReaderFor(original.Bytes).Read(typeInfo); + + Assert.AreEqual(CLType.Result, result.TypeInfo.Type); + Assert.AreEqual(Hex.ToHexString(original.Bytes), Hex.ToHexString(result.Bytes)); + Assert.AreEqual(0x00, result.Bytes[0]); // Err tag + var typed = result.ToResult(); + Assert.IsFalse(typed.Success); + Assert.AreEqual("Error!", typed.Error); + } + + // ─── Map ─────────────────────────────────────────────────────────────────── + + [Test] + public void ReadMapTest() + { + var original = CLValue.Map(new Dictionary + { + { CLValue.String("key1"), CLValue.U32(1) }, + { CLValue.String("key2"), CLValue.U32(2) } + }); + var typeInfo = new CLMapTypeInfo(CLType.String, CLType.U32); + var result = ReaderFor(original.Bytes).Read(typeInfo); + + Assert.AreEqual(CLType.Map, result.TypeInfo.Type); + var mapType = (CLMapTypeInfo)result.TypeInfo; + Assert.AreEqual(CLType.String, mapType.KeyType.Type); + Assert.AreEqual(CLType.U32, mapType.ValueType.Type); + Assert.AreEqual(Hex.ToHexString(original.Bytes), Hex.ToHexString(result.Bytes)); + + var dict = result.ToDictionary(); + Assert.AreEqual(2, dict.Count); + Assert.AreEqual(1u, dict["key1"]); + Assert.AreEqual(2u, dict["key2"]); + } + + [Test] + public void ReadEmptyMapTest() + { + var original = CLValue.EmptyMap(CLType.String, new CLKeyTypeInfo(KeyIdentifier.Hash)); + var typeInfo = new CLMapTypeInfo(CLType.String, new CLKeyTypeInfo(KeyIdentifier.Hash)); + var result = ReaderFor(original.Bytes).Read(typeInfo); + + Assert.AreEqual(CLType.Map, result.TypeInfo.Type); + Assert.AreEqual(Hex.ToHexString(original.Bytes), Hex.ToHexString(result.Bytes)); + Assert.AreEqual(0, result.ToDictionary().Count); + } + + // ─── Tuples ──────────────────────────────────────────────────────────────── + + [Test] + public void ReadTuple1Test() + { + var original = CLValue.Tuple1(CLValue.U32(17)); + var typeInfo = new CLTuple1TypeInfo(CLType.U32); + var result = ReaderFor(original.Bytes).Read(typeInfo); + + Assert.AreEqual(CLType.Tuple1, result.TypeInfo.Type); + Assert.AreEqual(CLType.U32, ((CLTuple1TypeInfo)result.TypeInfo).Type0.Type); + Assert.AreEqual(Hex.ToHexString(original.Bytes), Hex.ToHexString(result.Bytes)); + Assert.AreEqual(17u, result.ToTuple1().Item1); + } + + [Test] + public void ReadTuple2Test() + { + var original = CLValue.Tuple2(CLValue.U32(17), CLValue.U32(127)); + var typeInfo = new CLTuple2TypeInfo(CLType.U32, CLType.U32); + var result = ReaderFor(original.Bytes).Read(typeInfo); + + Assert.AreEqual(CLType.Tuple2, result.TypeInfo.Type); + Assert.AreEqual(Hex.ToHexString(original.Bytes), Hex.ToHexString(result.Bytes)); + var t = result.ToTuple2(); + Assert.AreEqual(17u, t.Item1); + Assert.AreEqual(127u, t.Item2); + } + + [Test] + public void ReadTuple2MixedTypesTest() + { + var original = CLValue.Tuple2(CLValue.U32(127), CLValue.String("ABCDE")); + var typeInfo = new CLTuple2TypeInfo(CLType.U32, CLType.String); + var result = ReaderFor(original.Bytes).Read(typeInfo); + + Assert.AreEqual(CLType.Tuple2, result.TypeInfo.Type); + Assert.AreEqual(Hex.ToHexString(original.Bytes), Hex.ToHexString(result.Bytes)); + var t = result.ToTuple2(); + Assert.AreEqual(127u, t.Item1); + Assert.AreEqual("ABCDE", t.Item2); + } + + [Test] + public void ReadTuple3Test() + { + var original = CLValue.Tuple3(CLValue.U32(17), CLValue.U32(127), CLValue.U32(255)); + var typeInfo = new CLTuple3TypeInfo(CLType.U32, CLType.U32, CLType.U32); + var result = ReaderFor(original.Bytes).Read(typeInfo); + + Assert.AreEqual(CLType.Tuple3, result.TypeInfo.Type); + Assert.AreEqual(Hex.ToHexString(original.Bytes), Hex.ToHexString(result.Bytes)); + var t = result.ToTuple3(); + Assert.AreEqual(17u, t.Item1); + Assert.AreEqual(127u, t.Item2); + Assert.AreEqual(255u, t.Item3); + } + + // ─── nested / compound types ─────────────────────────────────────────────── + + [Test] + public void ReadOptionSomeListTest() + { + // Option(Some(List(U32))) + var inner = CLValue.List(new[] { CLValue.U32(10), CLValue.U32(20) }); + var original = CLValue.Option(inner); + var typeInfo = new CLOptionTypeInfo(new CLListTypeInfo(CLType.U32)); + var result = ReaderFor(original.Bytes).Read(typeInfo); + + Assert.AreEqual(CLType.Option, result.TypeInfo.Type); + Assert.AreEqual(Hex.ToHexString(original.Bytes), Hex.ToHexString(result.Bytes)); + Assert.IsTrue(result.IsSome()); + } + + [Test] + public void ReadOptionNoneListTest() + { + // Option(None) with inner type List(U32) + var original = CLValue.OptionNone(new CLListTypeInfo(CLType.U32)); + var typeInfo = new CLOptionTypeInfo(new CLListTypeInfo(CLType.U32)); + var result = ReaderFor(original.Bytes).Read(typeInfo); + + Assert.AreEqual(CLType.Option, result.TypeInfo.Type); + Assert.IsTrue(result.IsNone()); + } + + [Test] + public void ReadListOfByteArraysTest() + { + // List(ByteArray(8)) + var p1 = Hex.Decode("0102030405060708"); + var p2 = Hex.Decode("090a0b0c0d0e0f00"); + var original = CLValue.List(new[] + { + CLValue.ByteArray(p1), + CLValue.ByteArray(p2) + }); + var typeInfo = new CLListTypeInfo(new CLByteArrayTypeInfo(8)); + var result = ReaderFor(original.Bytes).Read(typeInfo); + + Assert.AreEqual(CLType.List, result.TypeInfo.Type); + Assert.AreEqual(Hex.ToHexString(original.Bytes), Hex.ToHexString(result.Bytes)); + + var items = result.ToList(); + Assert.AreEqual(2, items.Count); + Assert.IsTrue(p1.SequenceEqual(items[0])); + Assert.IsTrue(p2.SequenceEqual(items[1])); + } + + [Test] + public void ReadTuple3MixedComplexTypesTest() + { + // Tuple3(I32, Bool, URef) + var uref = "uref-cdd5422295f6a61e86a4d3229b28dac2e67523c41e2aafed3a041362df7a8432-007"; + var original = CLValue.Tuple3( + CLValue.I32(123), + CLValue.Bool(true), + CLValue.URef(uref)); + var typeInfo = new CLTuple3TypeInfo(CLType.I32, CLType.Bool, CLType.URef); + var result = ReaderFor(original.Bytes).Read(typeInfo); + + Assert.AreEqual(CLType.Tuple3, result.TypeInfo.Type); + Assert.AreEqual(Hex.ToHexString(original.Bytes), Hex.ToHexString(result.Bytes)); + var t = result.ToTuple3(); + Assert.AreEqual(123, t.Item1); + Assert.AreEqual(true, t.Item2); + Assert.AreEqual(uref, t.Item3.ToString()); + } + + // ─── stream position and multi-value reads ───────────────────────────────── + + [Test] + public void ReadSequentialValuesAdvancesStreamTest() + { + // Pack three values back-to-back into a single MemoryStream and verify + // that each Read() advances the position so the next call reads correctly. + var u32Bytes = CLValue.U32(42u).Bytes; + var boolBytes = CLValue.Bool(true).Bytes; + var strBytes = CLValue.String("Casper").Bytes; + + using var ms = new MemoryStream(); + ms.Write(u32Bytes, 0, u32Bytes.Length); + ms.Write(boolBytes, 0, boolBytes.Length); + ms.Write(strBytes, 0, strBytes.Length); + ms.Position = 0; + + var clValueReader = new CLValueReader(new BinaryReader(ms)); + + var v1 = clValueReader.Read(CLType.U32); + var v2 = clValueReader.Read(CLType.Bool); + var v3 = clValueReader.Read(CLType.String); + + // All bytes consumed — stream at end + Assert.AreEqual(ms.Length, ms.Position); + + Assert.AreEqual(42u, v1.ToUInt32()); + Assert.AreEqual(true, v2.ToBoolean()); + Assert.AreEqual("Casper", v3.ToString()); + } + + // ─── bytes match CLValue factory output ──────────────────────────────────── + + [Test] + public void ReadBytesMatchCLValueFactoryOutputTest() + { + // CLValueReader.Read() must produce exactly the same Bytes as the + // corresponding CLValue factory method — verified against known hex strings + // from CLValueByteSerializerTest (the data-only portion, no length/type prefix). + + var serializer = new CLValueByteSerializer(); + + void VerifyRoundTrip(CLValue original, CLTypeInfo typeInfo) + { + var result = ReaderFor(original.Bytes).Read(typeInfo); + Assert.AreEqual(Hex.ToHexString(original.Bytes), Hex.ToHexString(result.Bytes), + $"Bytes mismatch for CLType {typeInfo.Type}"); + // Also verify that the result can be re-serialized identically + var reEncoded = serializer.ToBytes(result); + var originalEncoded = serializer.ToBytes(original); + Assert.AreEqual(Hex.ToHexString(originalEncoded), Hex.ToHexString(reEncoded), + $"Re-serialized bytes mismatch for CLType {typeInfo.Type}"); + } + + VerifyRoundTrip(CLValue.Bool(false), CLType.Bool); + VerifyRoundTrip(CLValue.I32(-10), CLType.I32); + VerifyRoundTrip(CLValue.I64(-16), CLType.I64); + VerifyRoundTrip(CLValue.U8(0x7F), CLType.U8); + VerifyRoundTrip(CLValue.U32(uint.MaxValue), CLType.U32); + VerifyRoundTrip(CLValue.U64(ulong.MaxValue), CLType.U64); + VerifyRoundTrip(CLValue.U128(new BigInteger(ulong.MaxValue)), CLType.U128); + VerifyRoundTrip(CLValue.U256(new BigInteger(ulong.MaxValue)), CLType.U256); + VerifyRoundTrip(CLValue.U512(new BigInteger(ulong.MaxValue)), CLType.U512); + VerifyRoundTrip(CLValue.Unit(), CLType.Unit); + VerifyRoundTrip(CLValue.String("Hello, Casper!"), CLType.String); + VerifyRoundTrip(CLValue.URef("uref-000102030405060708090a0b0c0d0e0f000102030405060708090a0b0c0d0e0f-007"), + CLType.URef); + VerifyRoundTrip(CLValue.List(new[] { CLValue.U32(1), CLValue.U32(2) }), + new CLListTypeInfo(CLType.U32)); + VerifyRoundTrip(CLValue.ByteArray(Hex.Decode("0102030405060708")), + new CLByteArrayTypeInfo(8)); + VerifyRoundTrip(CLValue.Ok(CLValue.U8(0xFF), new CLTypeInfo(CLType.String)), + new CLResultTypeInfo(CLType.U8, CLType.String)); + VerifyRoundTrip(CLValue.Tuple1(CLValue.U32(17)), new CLTuple1TypeInfo(CLType.U32)); + VerifyRoundTrip(CLValue.Tuple2(CLValue.U32(17), CLValue.U32(127)), + new CLTuple2TypeInfo(CLType.U32, CLType.U32)); + VerifyRoundTrip(CLValue.Tuple3(CLValue.U32(17), CLValue.U32(127), CLValue.U32(17)), + new CLTuple3TypeInfo(CLType.U32, CLType.U32, CLType.U32)); + } + } +} diff --git a/Casper.Network.SDK/ByteSerializers/CLValueByteSerializer.cs b/Casper.Network.SDK/ByteSerializers/CLValueByteSerializer.cs index d4fc8eb..d4577a0 100644 --- a/Casper.Network.SDK/ByteSerializers/CLValueByteSerializer.cs +++ b/Casper.Network.SDK/ByteSerializers/CLValueByteSerializer.cs @@ -39,7 +39,7 @@ public CLValue FromReader(BinaryReader reader) var dataBytes = reader.ReadBytes((int)dataLength); // read type info recursively - var typeInfo = CLTypeFromBytes(reader); + var typeInfo = reader.ReadCLTypeInfo(); return new CLValue(dataBytes, typeInfo); } diff --git a/Casper.Network.SDK/CES/CESEvent.cs b/Casper.Network.SDK/CES/CESEvent.cs new file mode 100644 index 0000000..5f359fa --- /dev/null +++ b/Casper.Network.SDK/CES/CESEvent.cs @@ -0,0 +1,191 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; +using Casper.Network.SDK.Types; +using Casper.Network.SDK.Utils; + +namespace Casper.Network.SDK.CES +{ + /// + /// A fully parsed CES event containing the event name, its typed fields, and, optionally, the + /// execution-context metadata of the event. + /// + public class CESEvent + { + [JsonPropertyName("name")] + public string Name { get; } + + [JsonPropertyName("fields")] + [JsonConverter(typeof(NamedArgListConverter))] + public IReadOnlyList Fields { get; } + + /// + /// The contract hash of the emitting contract (e.g. "hash-abc…def"). + /// Set by ; null when parsed in isolation. + /// + [JsonPropertyName("contract_hash")] + public string ContractHash { get; init; } + + /// + /// The contract-package hash of the emitting contract. + /// Set by ; null when parsed in isolation. + /// + [JsonPropertyName("contract_package_hash")] + public string ContractPackageHash { get; init; } + + /// + /// The key in the global state that stores the event (dictionary item) + /// Set by ; null when parsed in isolation. + /// + public GlobalStateKey TransformKey { get; init; } + + /// + /// Zero-based index of the inside the execution-result + /// effect list from which this event was extracted. + /// Set by ; defaults to 0 when the event + /// is parsed in isolation via . + /// + [JsonPropertyName("transform_id")] + public int TransformId { get; init; } + + /// + /// The string key that identifies this entry inside the contract's __events + /// dictionary (i.e. the sequential event counter emitted by the contract). + /// Set by ; null when parsed in isolation. + /// + [JsonPropertyName("event_id")] + public string EventId { get; init; } + + [JsonConstructor] + public CESEvent(string name, IReadOnlyList fields) + { + Name = name; + Fields = fields; + } + + /// + /// Returns the CLValue of the field with the given name, or null if not found. + /// + public CLValue this[string fieldName] => + Fields.FirstOrDefault(f => f.Name == fieldName)?.Value; + + // ───────────────────────────────────────────────────────────────────── + // Parse + // ───────────────────────────────────────────────────────────────────── + + /// + /// Parses a single CES event from its raw bytes using the given contract schema. + /// + /// + /// The raw bytes of one event entry (value) from the __events CLValue map. + /// + /// + /// The contract schema obtained from . + /// + /// + /// Thrown when the event name found in is not present in + /// . + /// + public static CESEvent ParseEvent(byte[] rawBytes, CESContractSchema schema) + { + // Depending on the origin of the rawBytes, the actual payload can be prepended with a u32 LE length value. + // Detect and skip that outer wrapper transparently. + var offset = 0; + if (rawBytes.Length >= 4) + { + var declaredLen = BitConverter.ToUInt32(rawBytes, 0); + if (!BitConverter.IsLittleEndian) + declaredLen = SwapU32(declaredLen); + if (declaredLen == rawBytes.Length - 4) + offset = 4; + } + + using var ms = new MemoryStream(rawBytes, offset, rawBytes.Length - offset); + using var reader = new BinaryReader(ms); + + var rawName = reader.ReadCLString(); + + // Strip the "event_" prefix that CES implementation prepend to event names. + const string prefix = "event_"; + var eventName = rawName.StartsWith(prefix) + ? rawName.Substring(prefix.Length) + : rawName; + + if (!schema.TryGetEventSchema(eventName, out var eventSchema)) + throw new KeyNotFoundException( + $"Event '{rawName}' not found in the contract schema."); + + var fields = new List(eventSchema.Fields.Count); + var clValueReader = new CLValueReader(reader); + + foreach (var schemaField in eventSchema.Fields) + { + var clValue = clValueReader.Read(schemaField.CLTypeInfo); + fields.Add(new NamedArg(schemaField.Name, clValue)); + } + + return new CESEvent(eventName, fields) + { + ContractHash = schema.ContractHash, + ContractPackageHash = schema.ContractPackageHash, + }; + } + + private static uint SwapU32(uint value) => + ((value & 0x000000FFu) << 24) | + ((value & 0x0000FF00u) << 8) | + ((value & 0x00FF0000u) >> 8) | + ((value & 0xFF000000u) >> 24); + + // ───────────────────────────────────────────────────────────────────── + // JSON converter for IReadOnlyList + // + // Each NamedArg is serialised by NamedArg.NamedArgConverter as the + // two-element JSON array ["fieldName", {CLValue}], so the field list + // becomes an array of such pairs: + // [["amount", {...}], ["sender", {...}], ...] + // ───────────────────────────────────────────────────────────────────── + + public class NamedArgListConverter : JsonConverter> + { + private static readonly NamedArg.NamedArgConverter _itemConverter = + new NamedArg.NamedArgConverter(); + + public override IReadOnlyList Read( + ref Utf8JsonReader reader, + Type typeToConvert, + JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.StartArray) + throw new JsonException("Expected start of array for CESEvent fields."); + + reader.Read(); // move past the outer '[' to the first element (or ']') + + var list = new List(); + while (reader.TokenType != JsonTokenType.EndArray) + { + // Each element is itself a two-element array ["name", {CLValue}]; + // NamedArgConverter.Read expects the reader to be at StartArray. + list.Add(_itemConverter.Read(ref reader, typeof(NamedArg), options)); + reader.Read(); // advance past the EndArray of the just-read NamedArg + } + + return list; + } + + public override void Write( + Utf8JsonWriter writer, + IReadOnlyList value, + JsonSerializerOptions options) + { + writer.WriteStartArray(); + foreach (var arg in value) + _itemConverter.Write(writer, arg, options); + writer.WriteEndArray(); + } + } + } +} diff --git a/Casper.Network.SDK/CES/CESParser.cs b/Casper.Network.SDK/CES/CESParser.cs new file mode 100644 index 0000000..8698e10 --- /dev/null +++ b/Casper.Network.SDK/CES/CESParser.cs @@ -0,0 +1,103 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Casper.Network.SDK.Types; + +namespace Casper.Network.SDK.CES +{ + /// + /// Parses CES (Casper Event Standard) schema and event bytes produced by + /// smart contracts following the make-software CES convention. + /// + /// Contracts store their event schema in the named key __events_schema + /// (a CLValue of type Any) and emit events into __events + /// (a CLValue of type Map(U32, Bytes)). + /// + /// + public static class CESParser + { + /// + /// Scans a list of execution-result transforms and returns every CES event emitted by + /// any of the watched contracts. + /// + /// + /// The Effect list from an (V1 or V2). + /// + /// + /// The list of instances to watch. + /// Each schema must have set; schemas + /// where it is null are silently skipped during matching. + /// + /// + /// Ordered list of instances found, in the same order as they + /// appear in . Never null; empty when no events are found. + /// + public static List GetEvents( + List transforms, + IReadOnlyList watchedContracts) + { + if (transforms == null) + throw new ArgumentNullException(nameof(transforms)); + if (watchedContracts == null) + throw new ArgumentNullException(nameof(watchedContracts)); + + var results = new List(); + + for (int i = 0; i < transforms.Count; i++) + { + var transform = transforms[i]; + + // 1. Key must be a dictionary entry. + if (transform.Key is not DictionaryKey) + continue; + + // 2. Kind must be a CLValue write. + if (transform.Kind is not WriteTransformKind writeKind) + continue; + + var clValue = writeKind.Value?.CLValue; + if (clValue == null) + continue; + + // 3. Parse the dictionary envelope. + CLValueDictionary dict; + try + { + dict = CLValueDictionary.Parse(clValue.Bytes); + } + catch + { + // Not a CES dictionary entry — skip. + continue; + } + + // 4. Match the seed address against the watched contracts. + var seedHex = dict.Seed.ToHexString(); + var schema = watchedContracts.FirstOrDefault(s => + s.EventsURef != null && s.EventsURef.ToHexString() == seedHex); + if (schema == null) + continue; + + // 5. Parse the event payload, stamping it with execution-result context. + try + { + var evt = CESEvent.ParseEvent(dict.Value, schema); + results.Add(new CESEvent(evt.Name, evt.Fields) + { + ContractHash = schema.ContractHash, + ContractPackageHash = schema.ContractPackageHash, + TransformKey = transform.Key, + TransformId = i, + EventId = dict.ItemKey, + }); + } + catch (KeyNotFoundException) + { + // Event name not present in this version of the schema — skip. + } + } + + return results; + } + } +} diff --git a/Casper.Network.SDK/CES/CESSchema.cs b/Casper.Network.SDK/CES/CESSchema.cs new file mode 100644 index 0000000..a09a7ff --- /dev/null +++ b/Casper.Network.SDK/CES/CESSchema.cs @@ -0,0 +1,371 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json.Serialization; +using System.Threading.Tasks; +using Casper.Network.SDK.Converters; +using Casper.Network.SDK.Types; +using Casper.Network.SDK.Utils; + +namespace Casper.Network.SDK.CES +{ + /// + /// Defines one field in a CES event schema: its name and Casper type. + /// + public class CESEventSchemaField + { + [JsonPropertyName("name")] + public string Name { get; } + + [JsonPropertyName("cl_type")] + [JsonConverter(typeof(CLTypeInfoConverter))] + public CLTypeInfo CLTypeInfo { get; } + + [JsonConstructor] + public CESEventSchemaField(string name, CLTypeInfo clTypeInfo) + { + Name = name; + CLTypeInfo = clTypeInfo; + } + } + + /// + /// Schema for a single named CES event type, listing its fields in order. + /// + public class CESEventSchema + { + [JsonPropertyName("event_name")] + public string EventName { get; } + + [JsonPropertyName("fields")] + public IReadOnlyList Fields { get; } + + [JsonConstructor] + public CESEventSchema(string eventName, IReadOnlyList fields) + { + EventName = eventName; + Fields = fields; + } + } + + /// + /// Full schema for a CES-compliant contract, parsed from the __events_schema named key. + /// Maps event names to their . + /// + public class CESContractSchema + { + [JsonPropertyName("events")] + public IReadOnlyDictionary Events { get; } + + /// + /// The contract hash (e.g. "hash-abc…def" or "entity-contract-abc…def") + /// that owns this schema. + /// Set by the caller after , or automatically + /// populated when the schema is fetched via . + /// + [JsonPropertyName("contract_hash")] + public string ContractHash { get; init; } + + /// + /// The contract-package hash supplied to (or set manually + /// after ). + /// + [JsonPropertyName("contract_package_hash")] + public string ContractPackageHash { get; init; } + + /// + /// The URef stored under the __events_schema named key of the contract. + /// Automatically populated by ; null when the schema + /// was obtained via directly. + /// + [JsonPropertyName("schema_uref")] + [JsonConverter(typeof(GlobalStateKey.GlobalStateKeyConverter))] + public URef SchemaURef { get; init; } + + /// + /// The URef stored under the __events named key of the contract. + /// This is the seed used to locate CES event entries in the global state transforms. + /// Automatically populated by ; null when the schema + /// was obtained via directly. + /// + [JsonPropertyName("events_uref")] + [JsonConverter(typeof(GlobalStateKey.GlobalStateKeyConverter))] + public URef EventsURef { get; init; } + + [JsonConstructor] + public CESContractSchema(IReadOnlyDictionary events) + { + Events = events; + } + + /// + /// Retrieves the schema for the given event name. + /// + /// + /// Thrown when is not present in this schema. + /// + public CESEventSchema GetEventSchema(string eventName) + { + if (!TryGetEventSchema(eventName, out var schema)) + throw new KeyNotFoundException($"Event '{eventName}' not found in the contract schema."); + return schema; + } + + /// + /// Tries to retrieve the schema for the given event name. + /// + public bool TryGetEventSchema(string eventName, out CESEventSchema schema) + { + return Events.TryGetValue(eventName, out schema); + } + + // ───────────────────────────────────────────────────────────────────── + // Parse + // ───────────────────────────────────────────────────────────────────── + + /// + /// Parses the raw bytes of the __events_schema CLValue (type Any) + /// into a . + /// + /// + /// The Bytes property of the CLValue retrieved from __events_schema. + /// + public static CESContractSchema ParseSchema(byte[] rawBytes) + { + using var ms = new MemoryStream(rawBytes); + using var reader = new BinaryReader(ms); + + var numEvents = reader.ReadInt32(); + var events = new Dictionary(numEvents); + + for (int i = 0; i < numEvents; i++) + { + var eventName = reader.ReadCLString(); + + var numFields = reader.ReadInt32(); + var fields = new List(numFields); + + for (int j = 0; j < numFields; j++) + { + var fieldName = reader.ReadCLString(); + var fieldType = reader.ReadCLTypeInfo(); + fields.Add(new CESEventSchemaField(fieldName, fieldType)); + } + + events[eventName] = new CESEventSchema(eventName, fields); + } + + return new CESContractSchema(events); + } + + // ───────────────────────────────────────────────────────────────────── + // Network factory + // ───────────────────────────────────────────────────────────────────── + + /// + /// Fetches and parses the CES event schema for a contract from the Casper network. + /// + /// + /// + /// The parameter accepts either a + /// contract hash or a contract-package hash: + /// + /// + /// + /// If the value starts with "contract-" (e.g. "contract-dead…beef") + /// it is treated as a direct contract hash and the package-resolution step is skipped. + /// The parameter is ignored in this case. + /// + /// + /// Otherwise it is treated as a contract-package hash + /// (e.g. "hash-abc…def" or "package-abc…def") + /// and the method enumerates the package to resolve the target contract version: + /// when is provided that exact version is selected; + /// when is null (the default) the highest-numbered + /// active (non-disabled) version is used. + /// + /// + /// + /// After resolving the contract the method fetches its named-key list to locate + /// the __events_schema and __events URefs, then queries the schema + /// CLValue directly from the __events_schema URef and parses it into a + /// . + /// The resulting schema has , , + /// , and automatically populated. + /// + /// + /// Both the legacy (ContractPackage) and the new Casper-2.x + /// (Package / entity) contract models are supported. + /// + /// + /// An active instance. + /// + /// Either a contract hash (prefix "contract-") or a contract-package hash + /// (e.g. "hash-abc…def" or "package-abc…def"). + /// + /// + /// The contract version number to load when a package hash is supplied. + /// Pass null (the default) to load the latest active version. + /// Ignored when a direct contract hash is provided. + /// + /// + /// A fully-populated with + /// and set. + /// When a direct contract hash is supplied, is null. + /// + /// + /// Thrown when or is null. + /// + /// + /// Thrown when a specific was requested but not found in the + /// package. + /// + /// + /// Thrown when the package has no active versions, the RPC result is in an unexpected + /// format, or the __events_schema named key is absent. + /// + public static async Task LoadAsync( + ICasperClient client, + string contractPackageHash, + uint? version = null) + { + if (client == null) + throw new ArgumentNullException(nameof(client)); + if (string.IsNullOrWhiteSpace(contractPackageHash)) + throw new ArgumentNullException(nameof(contractPackageHash)); + + string contractHash; + GlobalStateKey resolvedPackageHash; + + if (contractPackageHash.StartsWith("contract-", StringComparison.OrdinalIgnoreCase)) + { + // Input is already a contract hash — skip package resolution entirely. + contractHash = contractPackageHash; + resolvedPackageHash = null; + } + else + { + // ── 1. Fetch the package to enumerate versions ──────────────────── + var pkgResult = (await client.QueryGlobalState(contractPackageHash)).Parse(); + resolvedPackageHash = GlobalStateKey.FromString(contractPackageHash); + + // ── 2. Resolve the target contract version ──────────────────────── + if (pkgResult.StoredValue != null && + pkgResult.StoredValue.ContractPackage != null) + { + // Legacy node: Versions contains only active (non-disabled) entries. + var pkg = pkgResult.StoredValue.ContractPackage; + + ContractVersion chosen; + if (version.HasValue) + { + chosen = pkg.Versions.FirstOrDefault(v => v.Version == version.Value) + ?? throw new KeyNotFoundException( + $"Version {version.Value} not found in contract package '{contractPackageHash}'."); + } + else + { + chosen = pkg.Versions.OrderByDescending(v => v.Version).FirstOrDefault() + ?? throw new InvalidOperationException( + $"Contract package '{contractPackageHash}' has no active versions."); + } + + contractHash = chosen.Hash; // e.g. "contract-hash-abc…def" + } + else if (pkgResult.StoredValue != null && + pkgResult.StoredValue.Package != null) + { + // New node: Versions contains all versions; filter out disabled ones for latest. + var pkg = pkgResult.StoredValue.Package; + var disabledSet = new HashSet( + pkg.DisabledVersions?.Select(d => d.Version) ?? Enumerable.Empty()); + + EntityVersionAndHash chosen; + if (version.HasValue) + { + chosen = pkg.Versions.FirstOrDefault(v => v.EntityVersion.Version == version.Value) + ?? throw new KeyNotFoundException( + $"Version {version.Value} not found in contract package '{contractPackageHash}'."); + } + else + { + chosen = pkg.Versions + .Where(v => !disabledSet.Contains(v.EntityVersion.Version)) + .OrderByDescending(v => v.EntityVersion.Version) + .FirstOrDefault() + ?? throw new InvalidOperationException( + $"Contract package '{contractPackageHash}' has no active versions."); + } + + contractHash = chosen.AddressableEntity.ToString(); // e.g. "entity-contract-abc…def" + } + else + { + throw new InvalidOperationException( + $"GetPackage returned neither a ContractPackage nor a Package for '{contractPackageHash}'."); + } + } + + var contractKey = GlobalStateKey.FromString(contractHash); + + // ── 3. Fetch named keys to locate __events_schema and __events ──── + List namedKeys; + if (contractKey is AddressableEntityKey) + { + // New node (Casper 2.x): named keys are returned by state_get_entity. + var entityResult = (await client.GetEntity(contractHash)).Parse(); + namedKeys = entityResult.NamedKeys + ?? throw new InvalidOperationException( + $"No named keys returned for entity '{contractHash}'."); + + resolvedPackageHash ??= entityResult.Entity.Package; + } + else + { + // Legacy node: QueryGlobalState without a path returns the Contract + // object, which includes its named-key list. + var contractResult = (await client.QueryGlobalState(contractKey)).Parse(); + namedKeys = contractResult.StoredValue?.Contract?.NamedKeys + ?? throw new InvalidOperationException( + $"No named keys found for contract '{contractHash}'."); + + resolvedPackageHash ??= GlobalStateKey.FromString( + contractResult.StoredValue.Contract.ContractPackageHash); + } + + var schemaNamedKey = namedKeys.FirstOrDefault(k => k.Name == "__events_schema") + ?? throw new InvalidOperationException( + $"Named key '__events_schema' not found for contract '{contractHash}'."); + + var eventsNamedKey = namedKeys.FirstOrDefault(k => k.Name == "__events") + ?? throw new InvalidOperationException( + $"Named key '__events' not found for contract '{contractHash}'."); + + var schemaURef = schemaNamedKey.Key as URef + ?? throw new InvalidOperationException( + $"Expected a URef for '__events_schema' named key, got '{schemaNamedKey.Key?.GetType().Name}'."); + + var eventsURef = eventsNamedKey.Key as URef + ?? throw new InvalidOperationException( + $"Expected a URef for '__events' named key, got '{eventsNamedKey.Key?.GetType().Name}'."); + + // ── 4. Query the schema CLValue directly from its URef ──────────── + var schemaResult = (await client.QueryGlobalState(schemaURef)).Parse(); + + var clValue = schemaResult.StoredValue?.CLValue + ?? throw new InvalidOperationException( + $"No CLValue at '__events_schema' URef for contract '{contractHash}'."); + + // ── 5. Parse and annotate ───────────────────────────────────────── + var parsed = ParseSchema(clValue.Bytes); + return new CESContractSchema(parsed.Events) + { + ContractHash = contractKey.ToString(), + ContractPackageHash = resolvedPackageHash.ToString(), + SchemaURef = schemaURef, + EventsURef = eventsURef, + }; + } + } +} diff --git a/Casper.Network.SDK/Types/CLValueReader.cs b/Casper.Network.SDK/Types/CLValueReader.cs new file mode 100644 index 0000000..d3113b4 --- /dev/null +++ b/Casper.Network.SDK/Types/CLValueReader.cs @@ -0,0 +1,233 @@ +using System; +using System.IO; + +namespace Casper.Network.SDK.Types +{ + /// + /// Reads a from a binary stream given a descriptor. + /// The caller is responsible for knowing the type layout of the stream; no type tag bytes are read. + /// + public class CLValueReader + { + private readonly BinaryReader _reader; + + public CLValueReader(BinaryReader reader) + { + _reader = reader ?? throw new ArgumentNullException(nameof(reader)); + } + + /// + /// Reads from the underlying stream the bytes that represent a value of the given type + /// and returns a constructed from those bytes and the provided type info. + /// + public CLValue Read(CLTypeInfo typeInfo) + { + if (typeInfo == null) + throw new ArgumentNullException(nameof(typeInfo)); + + using var ms = new MemoryStream(); + AppendValueBytes(ms, typeInfo); + return new CLValue(ms.ToArray(), typeInfo); + } + + private void AppendValueBytes(MemoryStream ms, CLTypeInfo typeInfo) + { + switch (typeInfo.Type) + { + case CLType.Bool: + case CLType.U8: + { + ms.WriteByte(_reader.ReadByte()); + break; + } + + case CLType.I32: + case CLType.U32: + { + var bytes = _reader.ReadBytes(4); + ms.Write(bytes, 0, bytes.Length); + break; + } + + case CLType.I64: + case CLType.U64: + { + var bytes = _reader.ReadBytes(8); + ms.Write(bytes, 0, bytes.Length); + break; + } + + case CLType.U128: + case CLType.U256: + case CLType.U512: + { + // length-prefixed little-endian big integer: 1 byte length + N bytes + var len = _reader.ReadByte(); + ms.WriteByte(len); + if (len > 0) + { + var bytes = _reader.ReadBytes(len); + ms.Write(bytes, 0, bytes.Length); + } + break; + } + + case CLType.Unit: + break; // zero bytes on the wire + + case CLType.String: + { + // 4-byte LE length prefix followed by UTF-8 bytes + var lenBytes = _reader.ReadBytes(4); + ms.Write(lenBytes, 0, lenBytes.Length); + var len = ToInt32LE(lenBytes); + if (len > 0) + { + var strBytes = _reader.ReadBytes(len); + ms.Write(strBytes, 0, strBytes.Length); + } + break; + } + + case CLType.URef: + { + // 32-byte key + 1-byte access rights + var bytes = _reader.ReadBytes(33); + ms.Write(bytes, 0, bytes.Length); + break; + } + + case CLType.PublicKey: + { + // 1-byte algo prefix (already included in key size) + key bytes + int algo = _reader.PeekChar(); + var keyLen = algo == 0x01 + ? KeyAlgo.ED25519.GetKeySizeInBytes() + : KeyAlgo.SECP256K1.GetKeySizeInBytes(); + var bytes = _reader.ReadBytes(keyLen); + ms.Write(bytes, 0, bytes.Length); + break; + } + + case CLType.Key: + { + // 1-byte tag + variant payload; mirrors GlobalStateKey.ReadCLGlobalStateKey + int keyId = _reader.PeekChar(); + int keyLen; + if (keyId == (char)KeyIdentifier.EraInfo) + keyLen = 9; // 1 tag + 8 (u64) + else if (keyId == (char)KeyIdentifier.URef) + keyLen = 34; // 1 tag + 33 (URef) + else + keyLen = 33; // 1 tag + 32 (hash) + var bytes = _reader.ReadBytes(keyLen); + ms.Write(bytes, 0, bytes.Length); + break; + } + + case CLType.Option: + { + if (typeInfo is not CLOptionTypeInfo optionTypeInfo) + throw new Exception("Expected CLOptionTypeInfo for Option CLType."); + var tag = _reader.ReadByte(); + ms.WriteByte(tag); + if (tag != 0x00) // Some variant – read the inner value + AppendValueBytes(ms, optionTypeInfo.OptionType); + break; + } + + case CLType.List: + { + if (typeInfo is not CLListTypeInfo listTypeInfo) + throw new Exception("Expected CLListTypeInfo for List CLType."); + var countBytes = _reader.ReadBytes(4); + ms.Write(countBytes, 0, countBytes.Length); + var count = ToInt32LE(countBytes); + for (var i = 0; i < count; i++) + AppendValueBytes(ms, listTypeInfo.ListType); + break; + } + + case CLType.ByteArray: + { + if (typeInfo is not CLByteArrayTypeInfo baTypeInfo) + throw new Exception("Expected CLByteArrayTypeInfo for ByteArray CLType."); + var bytes = _reader.ReadBytes(baTypeInfo.Size); + ms.Write(bytes, 0, bytes.Length); + break; + } + + case CLType.Result: + { + if (typeInfo is not CLResultTypeInfo resultTypeInfo) + throw new Exception("Expected CLResultTypeInfo for Result CLType."); + var tag = _reader.ReadByte(); + ms.WriteByte(tag); + // 0x01 = Ok, 0x00 = Err + AppendValueBytes(ms, tag == 0x01 ? resultTypeInfo.Ok : resultTypeInfo.Err); + break; + } + + case CLType.Map: + { + if (typeInfo is not CLMapTypeInfo mapTypeInfo) + throw new Exception("Expected CLMapTypeInfo for Map CLType."); + var countBytes = _reader.ReadBytes(4); + ms.Write(countBytes, 0, countBytes.Length); + var count = ToInt32LE(countBytes); + for (var i = 0; i < count; i++) + { + AppendValueBytes(ms, mapTypeInfo.KeyType); + AppendValueBytes(ms, mapTypeInfo.ValueType); + } + break; + } + + case CLType.Tuple1: + { + if (typeInfo is not CLTuple1TypeInfo tuple1TypeInfo) + throw new Exception("Expected CLTuple1TypeInfo for Tuple1 CLType."); + AppendValueBytes(ms, tuple1TypeInfo.Type0); + break; + } + + case CLType.Tuple2: + { + if (typeInfo is not CLTuple2TypeInfo tuple2TypeInfo) + throw new Exception("Expected CLTuple2TypeInfo for Tuple2 CLType."); + AppendValueBytes(ms, tuple2TypeInfo.Type0); + AppendValueBytes(ms, tuple2TypeInfo.Type1); + break; + } + + case CLType.Tuple3: + { + if (typeInfo is not CLTuple3TypeInfo tuple3TypeInfo) + throw new Exception("Expected CLTuple3TypeInfo for Tuple3 CLType."); + AppendValueBytes(ms, tuple3TypeInfo.Type0); + AppendValueBytes(ms, tuple3TypeInfo.Type1); + AppendValueBytes(ms, tuple3TypeInfo.Type2); + break; + } + + default: + throw new Exception($"Unknown/unsupported CLType '{typeInfo.Type}'"); + } + } + + /// + /// Interprets four bytes as a little-endian signed 32-bit integer, regardless of host endianness. + /// + private static int ToInt32LE(byte[] bytes) + { + if (!BitConverter.IsLittleEndian) + { + var copy = new byte[4]; + Array.Copy(bytes, copy, 4); + Array.Reverse(copy); + return BitConverter.ToInt32(copy, 0); + } + return BitConverter.ToInt32(bytes, 0); + } + } +} diff --git a/Casper.Network.SDK/Types/GlobalStateKey/GlobalStateKey.cs b/Casper.Network.SDK/Types/GlobalStateKey/GlobalStateKey.cs index 1655fed..424f610 100644 --- a/Casper.Network.SDK/Types/GlobalStateKey/GlobalStateKey.cs +++ b/Casper.Network.SDK/Types/GlobalStateKey/GlobalStateKey.cs @@ -145,6 +145,9 @@ protected GlobalStateKey(string key, string keyPrefix) Key = keyPrefix + Hex.ToHexString(bytes); } + /// + /// Returns a string with the hex string representation of the key, without the permissions byte. + /// public string ToHexString() { return Hex.ToHexString(RawBytes); diff --git a/Casper.Network.SDK/Utils/BinaryReaderExtensions.cs b/Casper.Network.SDK/Utils/BinaryReaderExtensions.cs index 31e4033..235f318 100644 --- a/Casper.Network.SDK/Utils/BinaryReaderExtensions.cs +++ b/Casper.Network.SDK/Utils/BinaryReaderExtensions.cs @@ -385,5 +385,26 @@ public static TItem ReadCLItem(BinaryReader reader, CLTypeInfo typeInfo) return (TItem) item; } + + /// + /// Reads a from the binary stream. + /// + public static CLTypeInfo ReadCLTypeInfo(this BinaryReader reader) + { + var tag = (CLType)reader.ReadByte(); + + return tag switch + { + CLType.Option => new CLOptionTypeInfo(ReadCLTypeInfo(reader)), + CLType.List => new CLListTypeInfo(ReadCLTypeInfo(reader)), + CLType.ByteArray => new CLByteArrayTypeInfo(reader.ReadInt32()), + CLType.Result => new CLResultTypeInfo(ReadCLTypeInfo(reader), ReadCLTypeInfo(reader)), + CLType.Map => new CLMapTypeInfo(ReadCLTypeInfo(reader), ReadCLTypeInfo(reader)), + CLType.Tuple1 => new CLTuple1TypeInfo(ReadCLTypeInfo(reader)), + CLType.Tuple2 => new CLTuple2TypeInfo(ReadCLTypeInfo(reader), ReadCLTypeInfo(reader)), + CLType.Tuple3 => new CLTuple3TypeInfo(ReadCLTypeInfo(reader), ReadCLTypeInfo(reader), ReadCLTypeInfo(reader)), + _ => new CLTypeInfo(tag) + }; + } } } \ No newline at end of file diff --git a/Docs/Articles/CasperEventStandard.md b/Docs/Articles/CasperEventStandard.md new file mode 100644 index 0000000..6d87478 --- /dev/null +++ b/Docs/Articles/CasperEventStandard.md @@ -0,0 +1,246 @@ +# Casper Event Standard (CES) + +The **Casper Event Standard (CES)** is a convention adopted by contract developers for emitting and consuming typed events from Casper smart contracts. Contracts that follow CES store a self-describing schema alongside their events, making it possible to decode any event without out-of-band knowledge of its structure. + +The Casper .NET SDK provides the `Casper.Network.SDK.CES` namespace with three classes: `CESContractSchema` (schema loading and parsing), `CESEvent` (event parsing and field access), and `CESParser` (scanning execution results). + +--- + +## How CES Works + +A CES-compliant contract uses two special named keys for events: + +| Named key | Purpose | +|---|---|---| +| `__events_schema` | Binary-encoded schema listing every event type and its fields | +| `__events` | Ordered map of event index → raw event bytes | + +### Schema (`__events_schema`) + +The schema describes every event the contract can emit. It is stored as a CLValue of type `Any`, whose raw bytes encode a sequence of event descriptors: + +``` +u32 LE — number of events +for each event: + String — event name (4-byte LE length + UTF-8 bytes) + u32 LE — number of fields + for each field: + String — field name + CLType bytes — Casper binary type encoding (tag byte + optional inner types) +``` + +The `String` encoding used here is the standard Casper string encoding: a `u32` length followed by the UTF-8 content (no null terminator). + +### Events (`__events`) + +Each entry in the `__events` map is a `Bytes` (`Vec`) value. The raw bytes of one event entry are laid out as follows: + +``` +u32 LE — Vec outer length (= remaining bytes; auto-detected and skipped) +String — event name, prefixed with "event_" (e.g. "event_Mint") +for each field (in schema order): + raw field bytes — native Casper serialization, no CLValue wrapper +``` + +The `"event_"` prefix is stripped automatically when parsing the event raw bytes. + +--- + +## Loading a Contract Schema from the Network + +The simplest way to load a contract's schema is `CESContractSchema.LoadAsync`. It accepts either a **contract-package hash** or a direct **contract hash**, reads the named keys to locate `__events_schema` and `__events`, and fetches and parses the schema — all in a single call. + +### Using a contract-package hash + +When you pass a contract-package hash, the method resolves the latest active contract version automatically. You can also request a specific version with the optional `version` parameter. + +```csharp +using Casper.Network.SDK.CES; + +var client = new NetCasperClient("https://rpc.testnet.casperlabs.io/rpc"); + +// Load the schema for the latest active version of the contract package +CESContractSchema schema = await CESContractSchema.LoadAsync( + client, + "hash-17cb23ce7b6d663fc82bacbdd56a26dc722cabe7e69c84a3ca729bf3cb7fdc70"); +``` + +### Using a contract hash directly + +If you already know the contract hash, you can pass it directly and the package-resolution step is skipped entirely. Use `"contract-"` prefix instead of `"hash-"` for a contract hash argument.The `version` parameter is ignored in this case. + +```csharp +CESContractSchema schema = await CESContractSchema.LoadAsync( + client, + "contract-dead1234dead1234dead1234dead1234dead1234dead1234dead1234dead1234beef"); +``` + +The returned `CESContractSchema` is fully annotated: + +| Property | Description | +|---|------------------------------------------------------------------| +| `Events` | Dictionary of event name → `CESEventSchema` | +| `ContractHash` | Hash of the resolved (or supplied) contract version | +| `ContractPackageHash` | Package hash passed in. May be `null` when a direct contract hash was supplied. | +| `SchemaURef` | URef of the `__events_schema` named key | +| `EventsURef` | URef of the `__events` named key (used when scanning transforms) | + +Both legacy (Casper 1.x) and Casper 2.x contract models are supported automatically. + +### Parsing a Schema from Raw Bytes + +If you have already fetched the `__events_schema` CLValue from the network, you can parse it directly: + +```csharp +byte[] schemaBytes = /* raw bytes from __events_schema CLValue */; +CESContractSchema schema = CESContractSchema.ParseSchema(schemaBytes); + +// Inspect the event types the contract supports +foreach (var kvp in schema.Events) +{ + Console.WriteLine($"Event: {kvp.Key}"); + foreach (var field in kvp.Value.Fields) + Console.WriteLine($" {field.Name}: {field.CLTypeInfo.Type}"); +} +``` + +Note that a schema created via `ParseSchema` directly has `SchemaURef` and `EventsURef` set to `null`. Use `LoadAsync` when you need these URefs. + +--- + +## Parsing a Single Event + +To decode one event payload, pass the raw event bytes together with a schema to `CESEvent.ParseEvent`: + +```csharp +byte[] eventBytes = /* raw bytes of one entry from __events map */; +CESEvent evt = CESEvent.ParseEvent(eventBytes, schema); + +Console.WriteLine(evt.Name); // e.g. "event_Mint" + +// Access field values directly by name — returns CLValue, or null if not found +CLValue recipient = evt["recipient"]; +Console.WriteLine(recipient.ToGlobalStateKey()); // hash-1262d06e... + +CLValue amount = evt["amount"]; +Console.WriteLine(amount.ToBigInteger()); // 1000000000000000000 +``` + +The indexer `evt["fieldName"]` returns the `CLValue` of that field, or `null` if the field name is not found. The full list of fields is also available as `IReadOnlyList` through `evt.Fields`, which preserves both name and value. + +### Looking Up an Event Schema + +```csharp +if (schema.TryGetEventSchema("Mint", out CESEventSchema mintSchema)) +{ + Console.WriteLine($"Mint has {mintSchema.Fields.Count} fields."); +} +``` + +--- + +## Scanning Execution Results with `GetEvents` + +`CESParser.GetEvents` scans the transform list of an execution result and returns all CES events emitted by any of the watched contracts: + +```csharp +var events = CESParser.GetEvents( + executionResult.Effect, + new List { schema1, schema2 }); +``` + +For each transform the method: +1. Skips transforms whose key is not a `DictionaryKey`. +2. Skips transforms whose kind is not a `WriteTransformKind`. +3. Tries to parse the CLValue as a `CLValueDictionary`; skips on failure. +4. Matches the dictionary seed against each schema's `EventsURef` (access rights are ignored; only the 32-byte hash is compared). +5. Parses the matching event payload using the schema. +6. Silently skips events whose name is not present in the schema (graceful version mismatch handling). + +Each schema in the list must have `EventsURef` set (schemas loaded via `LoadAsync` always have it). Schemas with a `null` `EventsURef` are silently skipped. + +The returned `CESEvent` objects include execution-result context: + +| Property | Description | +|---|---| +| `Name` | Raw event name as stored in bytes (e.g. `"event_Transfer"`) | +| `Fields` | Ordered list of `NamedArg` field values | +| `ContractHash` | Propagated from the matched schema | +| `ContractPackageHash` | Propagated from the matched schema | +| `TransformKey` | `GlobalStateKey` of the transform that emitted the event | +| `TransformId` | Zero-based index in the effect list | +| `EventId` | Sequential event counter key from `__events` | + +--- + +## Data Model + +``` +CESContractSchema + ├── ContractHash: string + ├── ContractPackageHash: string + ├── SchemaURef: URef + ├── EventsURef: URef + └── Events: Dictionary + └── CESEventSchema + ├── EventName: string + └── Fields: IReadOnlyList + ├── Name: string + └── CLTypeInfo: CLTypeInfo + +CESEvent + ├── Name: string (raw name, e.g. "event_Transfer") + ├── Fields: IReadOnlyList + ├── ContractHash: string + ├── ContractPackageHash: string + ├── TransformKey: GlobalStateKey + ├── TransformId: int + └── EventId: string +``` + +--- + +## Real-World Example — Watching Multiple Contracts + +The following example loads schemas for two contracts, fetches a transaction, and prints the events emitted: + +```csharp +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using Casper.Network.SDK; +using Casper.Network.SDK.CES; + +var client = new NetCasperClient("https://node.testnet.casper.network/rpc"); + +// Load the schema for each contract to watch +var minterSchema = await CESContractSchema.LoadAsync( + client, + "hash-1262d06e53125ea098187fb4d1d5b10a7afed48e5e5eef182ed992fc5b100349"); + +var cep18Schema = await CESContractSchema.LoadAsync( + client, + "hash-17cb23ce7b6d663fc82bacbdd56a26dc722cabe7e69c84a3ca729bf3cb7fdc70"); + +// Fetch a transaction and extract its execution result transforms +var getTxResult = await client.GetTransaction( + "8e46a16fc8fc15c38405e092959fb20acc44dcfca1d1caecb9bc59d018f50df6"); +var txResult = getTxResult.Parse(); + +// Scan for CES events emitted by either watched contract +var events = CESParser.GetEvents( + txResult.ExecutionInfo.ExecutionResult.Effect, + new List { minterSchema, cep18Schema }); + +// Work with a specific event +var buyEvent = events.FirstOrDefault(e => e.Name == "Buy"); +if (buyEvent != null) +{ + Console.WriteLine("Buy token: " + buyEvent["token"].ToGlobalStateKey()); + Console.WriteLine("Buy amount: " + buyEvent["amount_token_out"].ToBigInteger()); +} + +// Serialize all events to JSON +var json = JsonSerializer.Serialize(events); +Console.WriteLine(json); +``` diff --git a/Docs/Articles/toc.yml b/Docs/Articles/toc.yml index 9f25e4b..e0ec0a6 100644 --- a/Docs/Articles/toc.yml +++ b/Docs/Articles/toc.yml @@ -9,4 +9,6 @@ href: KeyManagement.md - name: Working with CLValue href: WorkingWithCLValue.md + - name: Casper Event Standard (CES) + href: CasperEventStandard.md